From e5ed830301aec57f0979687fa4a02b2ef9e5cd4b Mon Sep 17 00:00:00 2001 From: hao Date: Sun, 18 Oct 2026 14:40:44 +0800 Subject: [PATCH 1/2] Release v1.2.2: smoother conversion and clearer feedback --- .gitignore | 3 +- AppResources/Info.plist | 4 +- AppResources/en.lproj/Localizable.strings | 97 +- AppResources/ja.lproj/Localizable.strings | 99 +- .../zh-Hans.lproj/Localizable.strings | 60 +- CHANGELOG.md | 20 + PRIVACY.ja.md | 8 +- PRIVACY.md | 8 +- Scripts/package-dmg.sh | 3 +- Scripts/validate-localizations.sh | 4 +- Sources/FastConvert/ConverterStore.swift | 956 +++++++++++------- Sources/FastConvert/DependencyHealth.swift | 63 +- Sources/FastConvert/FFmpegProcessRunner.swift | 282 ++++++ Sources/FastConvert/FastConvertApp.swift | 322 ++++-- .../FastConvert/SmartEnhanceExporter.swift | 2 +- .../ConversionFailureLocalizationTests.swift | 116 +++ .../ConversionPlanningTests.swift | 214 ++++ .../ConverterStoreTests.swift | 269 ++++- .../DependencyHealthTests.swift | 75 ++ .../FastConvertTests/EditorPreviewTests.swift | 114 +++ .../GeometryRegressionTests.swift | 126 +++ Tests/FastConvertTests/MediaNoticeTests.swift | 135 +++ .../FastConvertTests/MergeGeometryTests.swift | 138 +++ .../ProcessLifecycleTests.swift | 216 ++++ .../RealtimeProgressTests.swift | 182 ++++ docs/ARCHITECTURE.md | 15 +- docs/GITHUB_METADATA.md | 7 +- docs/INSTALLER-BUILD.md | 16 +- docs/MEDIA-REGRESSION.md | 44 +- docs/RELEASE-CHECKLIST.md | 14 +- 30 files changed, 3029 insertions(+), 583 deletions(-) create mode 100644 Sources/FastConvert/FFmpegProcessRunner.swift create mode 100644 Tests/FastConvertTests/ConversionFailureLocalizationTests.swift create mode 100644 Tests/FastConvertTests/ConversionPlanningTests.swift create mode 100644 Tests/FastConvertTests/DependencyHealthTests.swift create mode 100644 Tests/FastConvertTests/EditorPreviewTests.swift create mode 100644 Tests/FastConvertTests/GeometryRegressionTests.swift create mode 100644 Tests/FastConvertTests/MediaNoticeTests.swift create mode 100644 Tests/FastConvertTests/MergeGeometryTests.swift create mode 100644 Tests/FastConvertTests/ProcessLifecycleTests.swift create mode 100644 Tests/FastConvertTests/RealtimeProgressTests.swift diff --git a/.gitignore b/.gitignore index 72409d8..2464bf6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,5 @@ dist/ *.cer *.mobileprovision *.provisionprofile -MP4Flow.app/Contents/MacOS/MP4Flow -MP4Flow.app/Contents/Resources/ +MP4Flow.app/ Tests/.generated/ diff --git a/AppResources/Info.plist b/AppResources/Info.plist index fe27ee5..32675e3 100644 --- a/AppResources/Info.plist +++ b/AppResources/Info.plist @@ -9,8 +9,8 @@ CFBundleIconFileAppIcon CFBundleNameMP4Flow CFBundlePackageTypeAPPL - CFBundleShortVersionString1.2.1 - CFBundleVersion7 + CFBundleShortVersionString1.2.2 + CFBundleVersion8 CFBundleLocalizations enjazh-Hans LSMinimumSystemVersion13.0 diff --git a/AppResources/en.lproj/Localizable.strings b/AppResources/en.lproj/Localizable.strings index 21f31e6..1f36e44 100644 --- a/AppResources/en.lproj/Localizable.strings +++ b/AppResources/en.lproj/Localizable.strings @@ -5,34 +5,34 @@ "智能增强放大(macOS 27+)" = "Smart Enhance Upscale (macOS 27+)"; "清晰又省空间" = "Clear and Compact"; "尽量保留原画" = "Keep Quality"; -"所有设备都能播放" = "Play Anywhere"; +"所有设备都能播放" = "Broad Compatibility"; "节省更多空间" = "Save More"; "最稳妥转换(较慢)" = "Reliable (Slow)"; "只换成 MP4(最快)" = "MP4 Only (Fast)"; -"自动选择,能不压就不压。" = "Automatically."; -"支持 1280×1080 以下视频,最高 2 倍。" = "Supports input up to 1280 × 1080; up to 2×."; -"清晰省空间,适合大文件。" = "Keeps video clear while reducing large files."; -"保留更多细节,文件更大。" = "Keeps more detail; output files are larger."; -"旧设备和电视也能播放。" = "Works on older devices and TVs."; -"文件更小,旧设备可能不支持。" = "Smaller files; older devices may not play them."; -"转换失败时使用,速度较慢。" = "Use if other conversions fail; it is slower."; -"只换MP4,不压缩画面。" = "Changes to MP4 without compressing the picture."; +"自动选择,能不压就不压。" = "Copies video when compatible."; +"支持 1280×1080 以下视频,最高 2 倍。" = "≤1280 × 1080 input; up to 2×."; +"清晰省空间,适合大文件。" = "Quality for large files."; +"保留更多细节,文件更大。" = "More detail; larger files."; +"旧设备和电视也能播放。" = "For older devices and TVs."; +"文件更小,旧设备可能不支持。" = "Smaller; not for all devices."; +"转换失败时使用,速度较慢。" = "If conversion fails; slower."; +"只换MP4,不压缩画面。" = "Copies video; picture edits re-encode."; "文件更小" = "Smaller"; "清晰刚好" = "Balanced"; "画质更好" = "Larger"; -"文件更小,细节会少一点。" = "Less detail."; -"画面和大小都合适。" = "A good balance of quality and size."; -"保留更多细节,文件更大,速度更慢。" = "More detail."; +"文件更小,细节会少一点。" = "Smaller files; less detail."; +"画面和大小都合适。" = "Quality and size in balance."; +"保留更多细节,文件更大,速度更慢。" = "More detail; larger and slower."; "保持原画" = "Original"; "转1080P" = "1080p"; "转720P" = "720p"; "转576P" = "576p"; "转480P" = "480p"; -"保持原画尺寸。" = "Keeps the original dimensions."; -"输出1080P,放大不增细节。" = "Outputs 1080p; upscaling does not add detail."; -"输出720P,节省空间。" = "Outputs 720p to save space."; -"输出576P,适合宽屏。" = "Outputs 576p for common widescreen video."; -"输出480P,最省空间。" = "Outputs 480p for the smallest files."; +"保持原画尺寸。" = "Keep original dimensions."; +"输出1080P,放大不增细节。" = "1080p; upscaling adds no detail."; +"输出720P,节省空间。" = "720p, same proportions."; +"输出576P,适合宽屏。" = "576p, same proportions."; +"输出480P,最省空间。" = "480p, same proportions."; "等待中" = "Waiting"; "转换中" = "Converting"; "正在合并" = "Merging"; @@ -50,8 +50,8 @@ "兼容合并" = "Compatible Merge"; "无损合并" = "Lossless Merge"; "已处理 %d/%d" = "%d of %d processed"; -"当前方式已设定画质。" = "This conversion mode sets the quality."; -"只换格式,不压缩画面。" = "Changes the container without compressing video."; +"当前方式已设定画质。" = "Quality set by this mode."; +"只换格式,不压缩画面。" = "Copies video; picture edits re-encode."; "添加视频" = "Add Videos"; "选择输出位置" = "Choose Output Folder"; "正在检查 %d 个片段是否可无损合并" = "Checking whether %d clips can be merged losslessly"; @@ -64,7 +64,7 @@ "合并已取消。" = "Merge cancelled."; "%@未完成" = "%@ did not finish"; "%@完成" = "%@ complete"; -"片段参数不同,可使用兼容合并。" = "Clip settings differ. Use Compatible Merge instead."; +"请先统一规格转码,再合并;也可使用兼容合并。" = "Convert to matching settings first, or use Compatible Merge."; "FFmpeg 合并失败。" = "FFmpeg merge failed."; "无法启动 FFmpeg:%@" = "Could not start FFmpeg: %@"; "兼容合并失败:存在无法读取的视频流。" = "Compatible Merge failed because a video stream could not be read."; @@ -112,7 +112,7 @@ "智能增强放大(实验性)" = "Smart Enhance Upscale (Experimental)"; "自动放大" = "Auto Upscale"; "自动选择支持倍率。" = "Uses a supported scale factor automatically."; -"添加视频后显示可选分辨率。" = "Add a video to see available resolutions."; +"添加视频后显示可选分辨率。" = "Add video to see sizes."; "最高增强 2 倍。" = "Up to 2× enhancement."; "目标码率 %.1f Mbps" = "Target bitrate %.1f Mbps"; "硬件编码不可用。请改用“兼容优先:H.264 MP4(软件)”。" = "Hardware encoding is unavailable. Use the software H.264 option instead."; @@ -215,7 +215,7 @@ "检查更新,当前版本 v%@" = "Check updates. Current version v%@"; "MP4Flow 依赖本机的 FFmpeg 进行转码,但尚未检测到它。\n\n1. 打开「终端」\n2. 若尚未安装 Homebrew,请在官网完成安装\n3. 粘贴并执行:brew install ffmpeg\n4. 安装结束后,完全退出并重新打开 MP4Flow。" = "MP4Flow needs FFmpeg to convert video, but it was not found.\n\n1. Open Terminal\n2. Install Homebrew if needed\n3. Run: brew install ffmpeg\n4. Quit and reopen MP4Flow."; "中文" = "Chinese"; -"检测到片段的分辨率、帧率或音频参数不同,无法安全无损拼接。继续后将以队列中最大分辨率为目标尺寸:较小视频等比例放大;比例不同则居中补边,不会拉伸画面。输出统一编码为 H.264 + AAC MP4。" = "The clips have different resolutions, frame rates, or audio settings, so they cannot be merged safely without re-encoding. Compatible Merge uses the largest source resolution: smaller clips scale up proportionally, different aspect ratios are padded in the centre, and nothing is stretched. Output is H.264 + AAC MP4."; +"这些片段暂不适合直接拼接。建议先做统一规格的转码,再合并。\n\n也可选择「开始兼容合并」,自动统一规格后合并。以最大分辨率为目标,等比例缩放并居中补边,输出 H.264 + AAC MP4。" = "These clips cannot be joined directly. Convert them to matching settings first, then merge.\n\nOr use Compatible Merge to do both. Clips are scaled proportionally and padded to the largest resolution. Output: H.264 + AAC MP4."; "关于 MP4Flow" = "About MP4Flow"; "MP4Flow 使用帮助" = "MP4Flow Help"; "发现新版本 v%@" = "New version · v%@"; @@ -273,7 +273,7 @@ "请检查文件和输出文件夹权限后重试。" = "Check the file and output-folder permissions, then try again."; "请检查磁盘空间后重试。" = "Check available disk space, then try again."; "请检查输出文件夹权限和可用空间后重试。" = "Check output-folder permissions and available space, then try again."; -"请确认文件可播放,或使用「智能转换」生成新文件后重试。" = "Confirm the file plays, or use Smart Convert to create a new file and try again."; +"请确认文件可播放,或使用「智能转换」生成新文件后重试。" = "Check that the file plays. If needed, use Smart Convert to create a new file, then add it and retry."; "请确认视频可播放、磁盘可用后重试;仍失败请使用「最稳妥转换」。" = "Confirm the video plays and disk space is available; if it still fails, use Reliable Conversion."; "请释放空间或选择其他输出位置后重试。" = "Free up space or choose another output folder, then try again."; "转换长时间无进度,已安全停止" = "Conversion made no progress for too long and was stopped safely"; @@ -310,8 +310,8 @@ "请选择可写的文件夹或检查磁盘权限。" = "Choose a writable folder or check disk permissions."; "可用空间较少;建议至少保留 1 GB。" = "Free space is low; keep at least 1 GB free."; "处理策略" = "Processing Policy"; -"默认输出第一路视频和音频,并移除容器元数据和章节;开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "MP4Flow outputs the first video and audio track, and removes container metadata and chapters. It checks multiple audio tracks, subtitles, and HDR when conversion starts, then shows a notice in the queue."; -"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "By default, MP4Flow outputs the first video and audio track and removes container metadata and chapters. It checks multiple audio tracks, subtitles, and HDR when conversion starts, then shows a notice in the queue."; +"默认保留首路视频和音频;多音轨、字幕及 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."; "%@ · %d%%" = "%@ · %d%%"; "%@ · %@" = "%@ · %@"; "正在转换" = "Converting"; @@ -325,3 +325,50 @@ "已检查 %d 个文件夹;无法写入:%@" = "Checked %d folder(s); cannot write: %@"; "已检查 %d 个输出文件夹,可写。" = "Checked %d output folder(s); writable."; "可用空间:%@" = "Free space: %@"; +"视频直接复制,音频转为 AAC" = "Copying video; converting audio to AAC"; +"素材信息尚未确认" = "Media information has not been confirmed"; +"请确认文件可播放后重试;检测完成前不会开始转换。" = "Check that the file plays, then try again. Conversion waits until inspection succeeds."; +"检测到 %d 路视频;仅保留第一路。" = "%d video tracks detected; only the first will be kept."; +"检测到 %d 路音频;仅保留第一路。" = "%d audio tracks detected; only the first will be kept."; +"检测到 %d 条字幕;输出不保留字幕。" = "Subtitles (%d) will not be included in the output."; +"检测到 %d 个章节;输出不保留章节。" = "Chapters (%d) will not be included in the output."; +"HDR 将重新编码,色彩或动态范围可能改变;如需保留,请保持原尺寸并使用「%@」。" = "Re-encoding HDR may change colour or dynamic range. To preserve it, keep the original size and use %@."; +"非标准旋转角度将重绘画面;请核对输出方向与边缘。" = "A non-standard rotation angle will redraw the image. Check the output orientation and edges."; +"源文件已移动、删除或无法访问" = "The source file was moved, deleted, or is unavailable"; +"请在访达中确认源文件仍存在,重新添加后再试。" = "Find the source file in Finder, then add it again and retry."; +"FFprobe 不可用,无法验证输出" = "FFprobe is unavailable; output could not be verified"; +"请在「环境体检」修复 FFprobe 后重新转换。" = "Fix FFprobe in Environment Check, then convert again."; +"输出文件未通过媒体检查" = "The output file could not be verified"; +"请重新转换;仍失败请在「环境体检」检查 FFprobe,并确认磁盘可用。" = "Convert again. If it still fails, check FFprobe in Environment Check and confirm the disk is available."; +"请取消裁切和旋转后再增强;或先完成普通编辑,再添加新文件增强。" = "Remove crop and rotation edits before enhancing, or finish a normal edit and add the new file for enhancement."; +"请改用「智能转换」;智能增强需要受支持的 Apple 芯片与 macOS 27+。" = "Use Smart Convert instead. Smart Enhance requires supported Apple silicon and macOS 27 or later."; +"请检查剩余空间和输出文件夹权限,或改用本地文件夹后重试。" = "Check free space and output-folder permissions, or choose a local folder and retry."; +"请降低增强目标分辨率,关闭其他视频任务后重试。" = "Lower the enhancement resolution, close other video tasks, and retry."; +"请降低增强目标分辨率后重试。" = "Lower the enhancement resolution and retry."; +"输出收尾超时" = "Output finalization timed out"; +"请检查磁盘空间与连接,改用本地输出文件夹后重试。" = "Check disk space and connections, choose a local output folder, and retry."; +"正在取消…" = "Cancelling…"; +"转换组件不可用" = "Conversion tools unavailable"; +"FFmpeg 或 ffprobe 未安装,或无法运行。请先在「环境体检」查看缺少的组件与修复方法。\n\n如未安装,可在终端执行:brew install ffmpeg\n安装后重新检查。" = "FFmpeg or ffprobe is missing or could not run. Open Environment Check to see which tool needs attention and how to fix it.\n\nIf not installed, run this in Terminal: brew install ffmpeg\nCheck again after installation."; +"首次启动不自动检查更新;之后通常每天检查一次,失败后稍后重试。" = "Updates are not checked automatically on first launch. After that, checks usually run daily, with a later retry if one fails."; +"上次未能检查更新,可手动重试。" = "The last update check failed. You can retry manually."; +"请确认文件可播放;也可先用「智能转换」转为 MP4,再重新添加。" = "Check that the file plays. If needed, use Smart Convert to create an MP4, then add the new file."; +"后退 5 秒" = "Back 5 seconds"; +"前进 5 秒" = "Forward 5 seconds"; +"播放" = "Play"; +"暂停" = "Pause"; +"播放进度" = "Playback position"; +"音量" = "Volume"; +"完成 %d · 失败 %d · 取消 %d" = "Done %d · Failed %d · Cancelled %d"; +"无法加载剪辑预览" = "Could not load the trim preview"; +"无法加载裁切预览" = "Could not load the crop preview"; +"FFmpeg 无法运行" = "FFmpeg could not run"; +"FFprobe 无法运行" = "FFprobe could not run"; +"硬件加速不可用,仍可转换" = "Hardware acceleration unavailable; conversion still works"; +"需要重新编码时将使用软件处理,速度可能较慢。" = "When re-encoding is needed, software processing may be slower."; +"软件编码器不可用" = "Software encoder unavailable"; +"请更新或重新安装 FFmpeg,或改用「智能转换」后重试。" = "Update or reinstall FFmpeg, or try Smart Convert."; +"此视频流无法直接写入 MP4" = "This stream cannot be copied directly into MP4"; +"请使用「智能转换」重新编码后重试;仍失败请使用「最稳妥转换」。" = "Re-encode with Smart Convert. If it still fails, try Reliable (Slow)."; +"无法启动转换工具" = "Could not start the conversion tool"; +"请在「环境体检」检查 FFmpeg,修复安装或权限后重试。" = "Check FFmpeg in Environment Check, fix its installation or permissions, and try again."; diff --git a/AppResources/ja.lproj/Localizable.strings b/AppResources/ja.lproj/Localizable.strings index 2b8dca7..233093c 100644 --- a/AppResources/ja.lproj/Localizable.strings +++ b/AppResources/ja.lproj/Localizable.strings @@ -6,34 +6,34 @@ "智能增强放大(macOS 27+)" = "スマート高画質拡大(macOS 27+)"; "清晰又省空间" = "高画質・省容量"; "尽量保留原画" = "高画質を優先"; -"所有设备都能播放" = "幅広い端末で再生"; +"所有设备都能播放" = "幅広い機器に対応"; "节省更多空间" = "さらに容量を節約"; "最稳妥转换(较慢)" = "確実に変換(低速)"; "只换成 MP4(最快)" = "MP4に変換のみ(最速)"; -"自动选择,能不压就不压。" = "可能な限り再エンコードせずに自動選択します。"; -"支持 1280×1080 以下视频,最高 2 倍。" = "1280 × 1080以下の動画に対応。最大2倍。"; -"清晰省空间,适合大文件。" = "画質と容量のバランスがよく、大きなファイルに向いています。"; -"保留更多细节,文件更大。" = "より多くのディテールを保持します。出力ファイルは大きくなります。"; -"旧设备和电视也能播放。" = "古い端末やテレビでも再生しやすい形式です。"; -"文件更小,旧设备可能不支持。" = "ファイルは小さくなりますが、古い端末では再生できない場合があります。"; -"转换失败时使用,速度较慢。" = "ほかの変換に失敗したときに使います。処理速度は遅くなります。"; -"只换MP4,不压缩画面。" = "映像を圧縮せず、MP4コンテナに変換します。"; +"自动选择,能不压就不压。" = "対応映像はそのままコピー。"; +"支持 1280×1080 以下视频,最高 2 倍。" = "1280×1080以下・最大2倍。"; +"清晰省空间,适合大文件。" = "画質と容量を両立。"; +"保留更多细节,文件更大。" = "細部を優先。容量は増えます。"; +"旧设备和电视也能播放。" = "古い機器やテレビ向け。"; +"文件更小,旧设备可能不支持。" = "省容量。非対応の機器もあります。"; +"转换失败时使用,速度较慢。" = "変換失敗時に。処理は低速です。"; +"只换MP4,不压缩画面。" = "映像はコピー。画面変更時は再エンコード。"; "文件更小" = "小さい"; "清晰刚好" = "バランス"; "画质更好" = "高画質"; -"文件更小,细节会少一点。" = "ファイルを小さくします。ディテールは少し減ります。"; -"画面和大小都合适。" = "画質とファイルサイズのバランスが取れています。"; -"保留更多细节,文件更大,速度更慢。" = "より多くのディテールを保持します。ファイルは大きく、処理は遅くなります。"; +"文件更小,细节会少一点。" = "省容量。細部は減ります。"; +"画面和大小都合适。" = "画質と容量のバランス。"; +"保留更多细节,文件更大,速度更慢。" = "細部を優先。大容量・低速。"; "保持原画" = "元のサイズ"; "转1080P" = "1080pに変換"; "转720P" = "720pに変換"; "转576P" = "576pに変換"; "转480P" = "480pに変換"; -"保持原画尺寸。" = "元の解像度を保ちます。"; -"输出1080P,放大不增细节。" = "1080pで出力します。拡大してもディテールは増えません。"; -"输出720P,节省空间。" = "720pで出力し、容量を節約します。"; -"输出576P,适合宽屏。" = "576pで出力します。一般的なワイド画面に適しています。"; -"输出480P,最省空间。" = "480pで出力し、最も容量を節約します。"; +"保持原画尺寸。" = "元の解像度を保持。"; +"输出1080P,放大不增细节。" = "1080p出力。拡大で細部は増えません。"; +"输出720P,节省空间。" = "720p、縦横比を保持。"; +"输出576P,适合宽屏。" = "576p、縦横比を保持。"; +"输出480P,最省空间。" = "480p、縦横比を保持。"; "等待中" = "待機中"; "转换中" = "変換中"; "正在合并" = "結合中"; @@ -51,8 +51,8 @@ "兼容合并" = "互換結合"; "无损合并" = "ロスレス結合"; "已处理 %d/%d" = "%d/%d件を処理済み"; -"当前方式已设定画质。" = "この変換モードでは画質があらかじめ設定されています。"; -"只换格式,不压缩画面。" = "コンテナ形式のみを変更し、映像は圧縮しません。"; +"当前方式已设定画质。" = "このモードでは画質固定。"; +"只换格式,不压缩画面。" = "映像はコピー。画面変更時は再エンコード。"; "添加视频" = "動画を追加"; "选择输出位置" = "出力先フォルダを選択"; "正在检查 %d 个片段是否可无损合并" = "%d個のクリップをロスレス結合できるか確認中"; @@ -65,7 +65,7 @@ "合并已取消。" = "結合をキャンセルしました。"; "%@未完成" = "%@ は完了しませんでした"; "%@完成" = "%@ が完了しました"; -"片段参数不同,可使用兼容合并。" = "クリップの設定が異なります。互換結合を使用してください。"; +"请先统一规格转码,再合并;也可使用兼容合并。" = "同じ設定に変換してから結合するか、互換結合をご利用ください。"; "FFmpeg 合并失败。" = "FFmpegでの結合に失敗しました。"; "无法启动 FFmpeg:%@" = "FFmpegを起動できませんでした:%@"; "兼容合并失败:存在无法读取的视频流。" = "互換結合に失敗しました。読み取れない映像ストリームがあります。"; @@ -113,8 +113,8 @@ "智能增强放大(实验性)" = "スマート高画質拡大(実験的機能)"; "自动放大" = "自動拡大"; "自动选择支持倍率。" = "対応する倍率を自動的に選択します。"; -"添加视频后显示可选分辨率。" = "動画を追加すると選択可能な解像度が表示されます。"; -"最高增强 2 倍。" = "最大2倍まで高画質拡大します。"; +"添加视频后显示可选分辨率。" = "動画追加で解像度を表示。"; +"最高增强 2 倍。" = "最大2倍に拡大。"; "目标码率 %.1f Mbps" = "目標ビットレート %.1f Mbps"; "硬件编码不可用。请改用“兼容优先:H.264 MP4(软件)”。" = "ハードウェアエンコードを利用できません。「互換性優先:H.264 MP4(ソフトウェア)」を使用してください。"; "无法读取源文件或写入输出位置,请检查文件与权限。" = "元ファイルを読み取れないか、出力先に書き込めません。ファイルとアクセス権を確認してください。"; @@ -216,7 +216,7 @@ "检查更新,当前版本 v%@" = "アップデートを確認。現在のバージョン:v%@"; "MP4Flow 依赖本机的 FFmpeg 进行转码,但尚未检测到它。\n\n1. 打开「终端」\n2. 若尚未安装 Homebrew,请在官网完成安装\n3. 粘贴并执行:brew install ffmpeg\n4. 安装结束后,完全退出并重新打开 MP4Flow。" = "MP4Flowは動画の変換にローカルのFFmpegを使用しますが、見つかりませんでした。\n\n1. 「ターミナル」を開きます\n2. Homebrewが未インストールの場合は、公式サイトからインストールします\n3. 次を貼り付けて実行します:brew install ffmpeg\n4. 完了後、MP4Flowを完全に終了して開き直します。"; "中文" = "中国語"; -"检测到片段的分辨率、帧率或音频参数不同,无法安全无损拼接。继续后将以队列中最大分辨率为目标尺寸:较小视频等比例放大;比例不同则居中补边,不会拉伸画面。输出统一编码为 H.264 + AAC MP4。" = "クリップの解像度、フレームレート、または音声設定が異なるため、安全にロスレス結合できません。続行すると、キュー内で最大の解像度を目標サイズにします。小さい動画は比率を保って拡大し、比率が異なる動画には中央揃えで余白を追加するため、映像は引き伸ばされません。出力はH.264 + AAC MP4に統一されます。"; +"这些片段暂不适合直接拼接。建议先做统一规格的转码,再合并。\n\n也可选择「开始兼容合并」,自动统一规格后合并。以最大分辨率为目标,等比例缩放并居中补边,输出 H.264 + AAC MP4。" = "このクリップはそのまま結合できません。同じ設定に変換してから結合してください。\n\n「互換結合を開始」なら設定の統一と結合をまとめて行えます。最大解像度に合わせ、比率を保って拡大・余白を追加します。出力:H.264 + AAC MP4。"; "关于 MP4Flow" = "MP4Flowについて"; "MP4Flow 使用帮助" = "MP4Flowヘルプ"; "发现新版本 v%@" = "新しいバージョン v%@ が見つかりました"; @@ -274,7 +274,7 @@ "请检查文件和输出文件夹权限后重试。" = "ファイルと出力先フォルダのアクセス権を確認してから再試行してください。"; "请检查磁盘空间后重试。" = "ディスクの空き容量を確認してから再試行してください。"; "请检查输出文件夹权限和可用空间后重试。" = "出力先フォルダのアクセス権と空き容量を確認してから再試行してください。"; -"请确认文件可播放,或使用「智能转换」生成新文件后重试。" = "ファイルを再生できるか確認するか、「スマート変換」で新しいファイルを作成してから再試行してください。"; +"请确认文件可播放,或使用「智能转换」生成新文件后重试。" = "ファイルが再生できることを確認してください。必要に応じて「スマート変換」で新しいファイルを作成し、追加し直して再試行してください。"; "请确认视频可播放、磁盘可用后重试;仍失败请使用「最稳妥转换」。" = "動画を再生でき、ディスクを使用できることを確認してから再試行してください。引き続き失敗する場合は「確実に変換」を使用してください。"; "请释放空间或选择其他输出位置后重试。" = "空き容量を確保するか、別の出力先を選択してから再試行してください。"; "转换长时间无进度,已安全停止" = "変換の進行が長時間止まったため、安全に停止しました"; @@ -311,8 +311,8 @@ "请选择可写的文件夹或检查磁盘权限。" = "書き込み可能なフォルダを選ぶか、ディスクのアクセス権を確認してください。"; "可用空间较少;建议至少保留 1 GB。" = "空き容量が少なくなっています。少なくとも1 GBの空きを確保することをおすすめします。"; "处理策略" = "処理ポリシー"; -"默认输出第一路视频和音频,并移除容器元数据和章节;开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "MP4Flowは最初の映像トラックと音声トラックを出力し、コンテナメタデータとチャプターを削除します。変換開始時に複数音声、字幕、HDRを検出し、キューに通知を表示します。"; -"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "デフォルトでは最初の映像トラックと音声トラックのみを出力し、コンテナメタデータとチャプターを削除します。変換開始時に複数音声、字幕、HDRを検出し、キューに通知を表示します。"; +"默认保留首路视频和音频;多音轨、字幕及 HDR 的处理将在队列中提示。" = "先頭の映像・音声のみ出力。追加音声・字幕・HDRはキューで通知。"; +"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "スマート変換は対応する映像を可能な限りそのままコピーし、必要な場合のみ再エンコードします。最初の映像・音声トラックを出力し、コンテナメタデータとチャプターは削除します。追加トラック、字幕、チャプター、HDRの再エンコードなどで内容が変わる場合にお知らせします。"; "%@ · %d%%" = "%@・%d%%"; "%@ · %@" = "%@・%@"; "正在转换" = "変換中"; @@ -326,3 +326,50 @@ "已检查 %d 个文件夹;无法写入:%@" = "%d個のフォルダを確認しました。書き込めません:%@"; "已检查 %d 个输出文件夹,可写。" = "%d個の出力先フォルダを確認しました。書き込み可能です。"; "可用空间:%@" = "空き容量:%@"; +"视频直接复制,音频转为 AAC" = "映像はそのままコピー・音声をAACに変換中"; +"素材信息尚未确认" = "素材情報を確認できていません"; +"请确认文件可播放后重试;检测完成前不会开始转换。" = "ファイルが再生できることを確認して再試行してください。確認が完了するまで変換は開始しません。"; +"检测到 %d 路视频;仅保留第一路。" = "映像トラックが%d個あります。最初のトラックのみ保持します。"; +"检测到 %d 路音频;仅保留第一路。" = "音声トラックが%d個あります。最初のトラックのみ保持します。"; +"检测到 %d 条字幕;输出不保留字幕。" = "字幕トラックが%d個あります。字幕は出力に保持されません。"; +"检测到 %d 个章节;输出不保留章节。" = "チャプターが%d個あります。チャプターは出力に保持されません。"; +"HDR 将重新编码,色彩或动态范围可能改变;如需保留,请保持原尺寸并使用「%@」。" = "HDRの再エンコードで色やダイナミックレンジが変わる場合があります。保持するには元のサイズで「%@」を使用してください。"; +"非标准旋转角度将重绘画面;请核对输出方向与边缘。" = "標準以外の回転角度では映像が再描画されます。出力の向きと画面端を確認してください。"; +"源文件已移动、删除或无法访问" = "元のファイルが移動・削除されたか、アクセスできません"; +"请在访达中确认源文件仍存在,重新添加后再试。" = "Finderで元のファイルを確認し、追加し直して再試行してください。"; +"FFprobe 不可用,无法验证输出" = "FFprobeが使えないため、出力を確認できません"; +"请在「环境体检」修复 FFprobe 后重新转换。" = "「環境チェック」でFFprobeを修復してから、もう一度変換してください。"; +"输出文件未通过媒体检查" = "出力ファイルの検証に失敗しました"; +"请重新转换;仍失败请在「环境体检」检查 FFprobe,并确认磁盘可用。" = "もう一度変換してください。解決しない場合は「環境チェック」でFFprobeを確認し、ディスクが使用できることも確認してください。"; +"请取消裁切和旋转后再增强;或先完成普通编辑,再添加新文件增强。" = "切り抜きと回転を解除して高画質拡大するか、通常の編集を先に完了し、新しいファイルを追加して高画質拡大してください。"; +"请改用「智能转换」;智能增强需要受支持的 Apple 芯片与 macOS 27+。" = "「スマート変換」を使用してください。スマート高画質拡大には対応するAppleシリコンとmacOS 27以降が必要です。"; +"请检查剩余空间和输出文件夹权限,或改用本地文件夹后重试。" = "空き容量と出力先フォルダのアクセス権を確認するか、ローカルフォルダに変更して再試行してください。"; +"请降低增强目标分辨率,关闭其他视频任务后重试。" = "高画質拡大の出力解像度を下げ、ほかの動画処理を終了してから再試行してください。"; +"请降低增强目标分辨率后重试。" = "高画質拡大の出力解像度を下げて再試行してください。"; +"输出收尾超时" = "出力の仕上げ処理がタイムアウトしました"; +"请检查磁盘空间与连接,改用本地输出文件夹后重试。" = "ディスクの空き容量と接続を確認し、ローカルの出力先フォルダに変更して再試行してください。"; +"正在取消…" = "キャンセル中…"; +"转换组件不可用" = "変換ツールを使用できません"; +"FFmpeg 或 ffprobe 未安装,或无法运行。请先在「环境体检」查看缺少的组件与修复方法。\n\n如未安装,可在终端执行:brew install ffmpeg\n安装后重新检查。" = "FFmpegまたはffprobeがインストールされていないか、実行できません。「環境チェック」で不足している項目と修復方法を確認してください。\n\n未インストールの場合はターミナルで実行:brew install ffmpeg\nインストール後に再チェックしてください。"; +"首次启动不自动检查更新;之后通常每天检查一次,失败后稍后重试。" = "初回起動時は更新を自動確認しません。その後は通常1日1回確認し、失敗した場合は時間をおいて再試行します。"; +"上次未能检查更新,可手动重试。" = "前回の更新確認に失敗しました。手動で再試行できます。"; +"请确认文件可播放;也可先用「智能转换」转为 MP4,再重新添加。" = "ファイルが再生できることを確認してください。必要に応じて「スマート変換」でMP4に変換し、新しいファイルを追加し直してください。"; +"后退 5 秒" = "5秒戻る"; +"前进 5 秒" = "5秒進む"; +"播放" = "再生"; +"暂停" = "一時停止"; +"播放进度" = "再生位置"; +"音量" = "音量"; +"完成 %d · 失败 %d · 取消 %d" = "完了 %d・失敗 %d・キャンセル %d"; +"无法加载剪辑预览" = "トリミングのプレビューを読み込めません"; +"无法加载裁切预览" = "切り抜きのプレビューを読み込めません"; +"FFmpeg 无法运行" = "FFmpegを実行できません"; +"FFprobe 无法运行" = "FFprobeを実行できません"; +"硬件加速不可用,仍可转换" = "ハードウェア高速化は使えませんが、変換は可能です"; +"需要重新编码时将使用软件处理,速度可能较慢。" = "再エンコードが必要な場合はソフトウェア処理となり、時間がかかることがあります。"; +"软件编码器不可用" = "ソフトウェアエンコーダーを利用できません"; +"请更新或重新安装 FFmpeg,或改用「智能转换」后重试。" = "FFmpegを更新または再インストールするか、「スマート変換」でお試しください。"; +"此视频流无法直接写入 MP4" = "このストリームはMP4に直接コピーできません"; +"请使用「智能转换」重新编码后重试;仍失败请使用「最稳妥转换」。" = "「スマート変換」で再エンコードしてください。失敗する場合は「確実に変換(低速)」をお試しください。"; +"无法启动转换工具" = "変換ツールを起動できません"; +"请在「环境体检」检查 FFmpeg,修复安装或权限后重试。" = "「環境チェック」でFFmpegを確認し、インストールやアクセス権を修復してから再試行してください。"; diff --git a/AppResources/zh-Hans.lproj/Localizable.strings b/AppResources/zh-Hans.lproj/Localizable.strings index ce029d2..a437884 100644 --- a/AppResources/zh-Hans.lproj/Localizable.strings +++ b/AppResources/zh-Hans.lproj/Localizable.strings @@ -127,8 +127,8 @@ "请选择可写的文件夹或检查磁盘权限。" = "请选择可写的文件夹或检查磁盘权限。"; "可用空间较少;建议至少保留 1 GB。" = "可用空间较少;建议至少保留 1 GB。"; "处理策略" = "处理策略"; -"默认输出第一路视频和音频,并移除容器元数据和章节;开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "默认输出第一路视频和音频,并移除容器元数据和章节;开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。"; -"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。"; +"默认保留首路视频和音频;多音轨、字幕及 HDR 的处理将在队列中提示。" = "默认保留首路视频和音频;多音轨、字幕及 HDR 的处理将在队列中提示。"; +"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "智能转换优先直接复制兼容的视频流,仅在需要时重新编码。默认只输出第一路视频和音频,移除容器元数据和章节;检测到额外轨道、字幕、章节或 HDR 转码等内容变化时会提示。"; "%@ · %d%%" = "%@ · %d%%"; "%@ · %@" = "%@ · %@"; "正在转换" = "正在转换"; @@ -142,3 +142,59 @@ "已检查 %d 个文件夹;无法写入:%@" = "已检查 %d 个文件夹;无法写入:%@"; "已检查 %d 个输出文件夹,可写。" = "已检查 %d 个输出文件夹,可写。"; "可用空间:%@" = "可用空间:%@"; +"视频直接复制,音频转为 AAC" = "视频直接复制,音频转为 AAC"; +"请先统一规格转码,再合并;也可使用兼容合并。" = "请先统一规格转码,再合并;也可使用兼容合并。"; +"这些片段暂不适合直接拼接。建议先做统一规格的转码,再合并。\n\n也可选择「开始兼容合并」,自动统一规格后合并。以最大分辨率为目标,等比例缩放并居中补边,输出 H.264 + AAC MP4。" = "这些片段暂不适合直接拼接。建议先做统一规格的转码,再合并。\n\n也可选择「开始兼容合并」,自动统一规格后合并。以最大分辨率为目标,等比例缩放并居中补边,输出 H.264 + AAC MP4。"; +"素材信息尚未确认" = "素材信息尚未确认"; +"请确认文件可播放后重试;检测完成前不会开始转换。" = "请确认文件可播放后重试;检测完成前不会开始转换。"; +"检测到 %d 路视频;仅保留第一路。" = "检测到 %d 路视频;仅保留第一路。"; +"检测到 %d 路音频;仅保留第一路。" = "检测到 %d 路音频;仅保留第一路。"; +"检测到 %d 条字幕;输出不保留字幕。" = "检测到 %d 条字幕;输出不保留字幕。"; +"检测到 %d 个章节;输出不保留章节。" = "检测到 %d 个章节;输出不保留章节。"; +"HDR 将重新编码,色彩或动态范围可能改变;如需保留,请保持原尺寸并使用「%@」。" = "HDR 将重新编码,色彩或动态范围可能改变;如需保留,请保持原尺寸并使用「%@」。"; +"非标准旋转角度将重绘画面;请核对输出方向与边缘。" = "非标准旋转角度将重绘画面;请核对输出方向与边缘。"; +"源文件已移动、删除或无法访问" = "源文件已移动、删除或无法访问"; +"请在访达中确认源文件仍存在,重新添加后再试。" = "请在访达中确认源文件仍存在,重新添加后再试。"; +"FFprobe 不可用,无法验证输出" = "FFprobe 不可用,无法验证输出"; +"请在「环境体检」修复 FFprobe 后重新转换。" = "请在「环境体检」修复 FFprobe 后重新转换。"; +"输出文件未通过媒体检查" = "输出文件未通过媒体检查"; +"请重新转换;仍失败请在「环境体检」检查 FFprobe,并确认磁盘可用。" = "请重新转换;仍失败请在「环境体检」检查 FFprobe,并确认磁盘可用。"; +"请取消裁切和旋转后再增强;或先完成普通编辑,再添加新文件增强。" = "请取消裁切和旋转后再增强;或先完成普通编辑,再添加新文件增强。"; +"请改用「智能转换」;智能增强需要受支持的 Apple 芯片与 macOS 27+。" = "请改用「智能转换」;智能增强需要受支持的 Apple 芯片与 macOS 27+。"; +"请检查剩余空间和输出文件夹权限,或改用本地文件夹后重试。" = "请检查剩余空间和输出文件夹权限,或改用本地文件夹后重试。"; +"请降低增强目标分辨率,关闭其他视频任务后重试。" = "请降低增强目标分辨率,关闭其他视频任务后重试。"; +"请降低增强目标分辨率后重试。" = "请降低增强目标分辨率后重试。"; +"输出收尾超时" = "输出收尾超时"; +"请检查磁盘空间与连接,改用本地输出文件夹后重试。" = "请检查磁盘空间与连接,改用本地输出文件夹后重试。"; +"正在取消…" = "正在取消…"; +"转换组件不可用" = "转换组件不可用"; +"FFmpeg 或 ffprobe 未安装,或无法运行。请先在「环境体检」查看缺少的组件与修复方法。\n\n如未安装,可在终端执行:brew install ffmpeg\n安装后重新检查。" = "FFmpeg 或 ffprobe 未安装,或无法运行。请先在「环境体检」查看缺少的组件与修复方法。\n\n如未安装,可在终端执行:brew install ffmpeg\n安装后重新检查。"; +"首次启动不自动检查更新;之后通常每天检查一次,失败后稍后重试。" = "首次启动不自动检查更新;之后通常每天检查一次,失败后稍后重试。"; +"上次未能检查更新,可手动重试。" = "上次未能检查更新,可手动重试。"; +"请确认文件可播放;也可先用「智能转换」转为 MP4,再重新添加。" = "请确认文件可播放;也可先用「智能转换」转为 MP4,再重新添加。"; +"后退 5 秒" = "后退 5 秒"; +"前进 5 秒" = "前进 5 秒"; +"播放" = "播放"; +"暂停" = "暂停"; +"播放进度" = "播放进度"; +"音量" = "音量"; +"完成 %d · 失败 %d · 取消 %d" = "完成 %d · 失败 %d · 取消 %d"; +"所有设备都能播放" = "兼容更多设备"; +"旧设备和电视也能播放。" = "更适合旧设备和电视。"; +"只换MP4,不压缩画面。" = "直接封装不压缩;修改画面需转码。"; +"无法加载剪辑预览" = "无法加载剪辑预览"; +"无法加载裁切预览" = "无法加载裁切预览"; +"FFmpeg 无法运行" = "FFmpeg 无法运行"; +"FFprobe 无法运行" = "FFprobe 无法运行"; +"硬件加速不可用,仍可转换" = "硬件加速不可用,仍可转换"; +"需要重新编码时将使用软件处理,速度可能较慢。" = "需要重新编码时将使用软件处理,速度可能较慢。"; +"输出720P,节省空间。" = "720P 输出,保持比例。"; +"输出576P,适合宽屏。" = "576P 输出,保持比例。"; +"输出480P,最省空间。" = "480P 输出,保持比例。"; +"只换格式,不压缩画面。" = "直接封装不压缩;修改画面需转码。"; +"软件编码器不可用" = "软件编码器不可用"; +"请更新或重新安装 FFmpeg,或改用「智能转换」后重试。" = "请更新或重新安装 FFmpeg,或改用「智能转换」后重试。"; +"此视频流无法直接写入 MP4" = "当前编码无法直接写入 MP4"; +"请使用「智能转换」重新编码后重试;仍失败请使用「最稳妥转换」。" = "请使用「智能转换」重新编码后重试;仍失败请使用「最稳妥转换」。"; +"无法启动转换工具" = "无法启动转换工具"; +"请在「环境体检」检查 FFmpeg,修复安装或权限后重试。" = "请在「环境体检」检查 FFmpeg,修复安装或权限后重试。"; diff --git a/CHANGELOG.md b/CHANGELOG.md index 011b2ca..50c0537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 1.2.2 — 2026-10-18 + +### English + +- Fixed progress displays that could stay at 0% and optimized 1080p-to-720p conversion, late clip seeking, and video copying with audio-only conversion. +- Improved cancellation, retries, and output verification, with fixes for rotation, aspect ratios, variable frame rates, and merging. +- Media warnings now focus on actual content changes, and errors provide clearer reasons and recovery steps. Refined English, Chinese, and Japanese layouts and language switching. + +### 日本語 + +- 進捗が0%のままになる問題を修正。1080pから720pへの変換、後半位置のトリミング、映像をコピーして音声のみ変換する処理を最適化しました。 +- キャンセル、再試行、出力検証を強化し、回転・縦横比・可変フレームレート・結合の不整合を修正しました。 +- 実際の内容変更に応じた注意事項を表示し、失敗理由と対処方法を明確にしました。日・英・中のレイアウトと言語切替も改善しました。 + +### 中文 + +- 修复转换进度停在 0% 的问题,优化 1080p 转 720p、后段精确剪切,以及保留兼容视频、仅转换音频的处理路径。 +- 加固取消、重试与输出校验,修复旋转、宽高比、可变帧率及合并中的一致性问题。 +- 素材提示聚焦真实内容变化,失败时提供清楚的原因与处理办法;优化中、英、日三语的布局和语言切换。 + ## 1.2.1 — 2026-10-17 ### English diff --git a/PRIVACY.ja.md b/PRIVACY.ja.md index 3bb65fd..5795a79 100644 --- a/PRIVACY.ja.md +++ b/PRIVACY.ja.md @@ -5,19 +5,19 @@ MP4Flow は、実行している Mac 上でメディアを処理するように ## MP4Flow が扱うデータ - アプリは、追加または選択した動画ファイルとフォルダを読み取ります。キューの表示と変換に必要な範囲で、メモリ内のサムネイルを作成し、メディアのプロパティを読み取ることがあります。 -- macOS のユーザーデフォルトには、選択した表示言語のみを保存します。キュー、動画のパス、変換設定、診断情報、アカウント情報、メディアライブラリは保存しません。 -- 変換、解析、トリミング、クロップ、回転、結合は、ローカルの `ffmpeg` と `ffprobe` 実行ファイルを通じて行います。このアプリのリリースには、テレメトリ、分析、広告 SDK、メディアアップロードのコードは含まれません。 +- 表示言語、更新設定(通知を非表示にしたバージョンを含む)、更新確認のスケジュール情報を macOS のユーザーデフォルトに保存します。スケジュール情報は、起動済みかどうかと直近の確認成功・失敗時刻です。キュー、動画のパス、変換設定、診断情報、アカウント情報、メディアライブラリは保存しません。 +- 通常の変換、解析、トリミング、クロップ、回転、結合は、ローカルの `ffmpeg` と `ffprobe` 実行ファイルを使用します。実験的なスマート高画質拡大は、この Mac 上の Apple の AVFoundation と VideoToolbox フレームワークを使用します。このアプリのリリースには、テレメトリ、分析、広告 SDK、メディアアップロードのコードは含まれません。 ## 更新確認 - MP4Flow は、最新の安定版を確認するために `https://api.github.com/repos/ixiehao/MP4Flow/releases/latest` へアクセスできます。このリクエストには固定の MP4Flow User-Agent と、GitHub およびネットワーク接続で通常扱われるネットワークメタデータだけが含まれます。動画の内容、ファイルパス、変換設定、アカウント情報、利用データは含まれません。 -- 自動確認は初期状態で有効ですが、初回起動時はオフラインです。以降の起動では、MP4Flow は最大で1日1回だけ確認し、一時的な失敗時は1時間待機します。**設定** で自動確認を無効にしても、いつでも **ヘルプ > アップデートを確認** を利用できます。 +- 自動確認は初期状態で有効ですが、初回起動時は更新を自動確認しません。以降の起動では、直近の確認成功から24時間以上、直近の確認失敗から1時間以上経過した場合に自動確認します。**設定** で自動確認を無効にしても、初回起動時を含め、いつでも **ヘルプ > アップデートを確認** を利用できます。 - 更新が見つかった場合も、選択したときに公開 GitHub リリースページを開くだけです。MP4Flow が自動で更新をダウンロード、インストール、実行することはありません。 ## ローカルに作成されるファイル - 出力は初期設定では元ファイルの隣に、または選択したフォルダに書き込まれます。MP4Flow が元ファイルを削除または上書きすることはありません。 -- 処理中の出力は、意図した結果の隣に UUID 名の隠し `.partial.mp4` ファイルとして作成され、FFmpeg が成功した後にのみ最終位置へ移動します。通常の失敗またはキャンセル後には、アプリが削除を試みます。クラッシュ、強制終了、電源断、ストレージエラーでは一時ファイルが残ることがあります。変換が実行中でないことを確認してから安全に削除できます。 +- 処理中の出力は、意図した結果の隣に UUID 名の隠し `.partial.mp4` ファイルとして作成され、処理と出力検証が成功した後にのみ最終位置へ移動します。通常の失敗またはキャンセル後には、アプリが削除を試みます。クラッシュ、強制終了、電源断、ストレージエラーでは一時ファイルが残ることがあります。変換が実行中でないことを確認してから安全に削除できます。 - ロスレス結合では、システムの一時ディレクトリに UUID 名のマニフェストを短時間作成します。そこには選択した元ファイルのパスが入り、結合後に削除されます。強制終了時は macOS が一時ディレクトリを清掃するまで残ることがあります。 ## メタデータとローカルツール diff --git a/PRIVACY.md b/PRIVACY.md index 3f26c27..e4d5204 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -5,19 +5,19 @@ MP4Flow is designed to process media on the Mac where it is run. This document d ## Data MP4Flow handles - The app reads the video files and folders that you add or choose. It may create an in-memory thumbnail and read media properties needed to show the queue and perform conversion. -- It stores only the selected interface language in macOS user defaults. It does not persist a queue, video paths, conversion settings, diagnostics, account data, or a media library. -- Conversion, probing, trimming, cropping, rotation, and merging run through the local `ffmpeg` and `ffprobe` executables. This app release contains no telemetry, analytics, advertising SDK, or media-upload code. +- It stores the interface language, update preferences (including the version whose notice was dismissed), and update-check scheduling state in macOS user defaults. Scheduling state records whether the app has launched before and the last successful and failed check times. It does not persist a queue, video paths, conversion settings, diagnostics, account data, or a media library. +- Standard conversion, probing, trimming, cropping, rotation, and merging use the local `ffmpeg` and `ffprobe` executables. Experimental Smart Enhance uses Apple's AVFoundation and VideoToolbox frameworks on this Mac. This app release contains no telemetry, analytics, advertising SDK, or media-upload code. ## Update checks - MP4Flow can check `https://api.github.com/repos/ixiehao/MP4Flow/releases/latest` for the latest stable release. The request includes only a fixed MP4Flow User-Agent and normal network metadata handled by GitHub and the network connection; it does not include video content, file paths, conversion settings, account data, or usage data. -- Automatic checks are enabled by default, but the first launch stays offline. From a later launch, MP4Flow checks at most once per day; a temporary failure backs off for one hour. You can turn automatic checks off in **Settings** and still use **Help > Check for Updates** at any time. +- Automatic checks are enabled by default, but the first launch does not perform an automatic update check. On later launches, automatic checks wait at least 24 hours after the last successful check and at least one hour after the last failed check. You can turn automatic checks off in **Settings** and still use **Help > Check for Updates** at any time, including the first launch. - A found update only opens its public GitHub release page when you choose to view it. MP4Flow never downloads, installs, or executes an update automatically. ## Files created locally - Outputs are written next to the source file by default, or in the folder you choose. Source files are never deleted or overwritten by MP4Flow. -- Work-in-progress outputs use a hidden, UUID-named `.partial.mp4` file beside the intended result and are moved into place only after FFmpeg succeeds. The app tries to remove those files after an ordinary failure or cancellation. A crash, forced quit, power loss, or storage error can leave a partial file behind; it is safe to delete after confirming that no conversion is active. +- Work-in-progress outputs use a hidden, UUID-named `.partial.mp4` file beside the intended result and are moved into place only after processing and output verification succeed. The app tries to remove those files after an ordinary failure or cancellation. A crash, forced quit, power loss, or storage error can leave a partial file behind; it is safe to delete after confirming that no conversion is active. - Lossless merge briefly creates a UUID-named manifest in the system temporary directory. It contains the selected source paths and is removed after the merge returns. A forced termination can leave that temporary manifest until macOS cleans its temporary directory. ## Metadata and local tools diff --git a/Scripts/package-dmg.sh b/Scripts/package-dmg.sh index a026dbc..b95e5c9 100755 --- a/Scripts/package-dmg.sh +++ b/Scripts/package-dmg.sh @@ -22,8 +22,9 @@ trap cleanup EXIT mkdir -p "$OUTPUT_DIR" cd "$PROJECT_DIR" +PACKAGING_PYTHON_OVERRIDE="${PACKAGING_PYTHON:-}" PACKAGING_PYTHON="" -for candidate in "${PACKAGING_PYTHON:-}" /opt/homebrew/opt/python@3.14/bin/python3.14 /opt/homebrew/bin/python3 /usr/local/bin/python3 python3; do +for candidate in "$PACKAGING_PYTHON_OVERRIDE" /opt/homebrew/opt/python@3.14/bin/python3.14 /opt/homebrew/bin/python3 /usr/local/bin/python3 python3; do [[ -n "$candidate" ]] || continue if "$candidate" -c 'import sys; raise SystemExit(sys.version_info < (3, 10))' 2>/dev/null; then PACKAGING_PYTHON="$candidate" diff --git a/Scripts/validate-localizations.sh b/Scripts/validate-localizations.sh index 19fe34b..19336c8 100755 --- a/Scripts/validate-localizations.sh +++ b/Scripts/validate-localizations.sh @@ -17,8 +17,8 @@ while IFS= read -r key; do missing=1 fi done -done < <(rg -o 'L10n\.(text|format)\("[^"]+"' Sources/FastConvert \ - | sed -E 's/.*L10n\.(text|format)\("([^"]+)".*/\2/' \ +done < <(rg -o 'L10n\.(text|format)\("[^"]+"|(reasonKey|solutionKey): "[^"]+"' Sources/FastConvert \ + | sed -E 's/.*L10n\.(text|format)\("([^"]+)".*/\2/; s/.*(reasonKey|solutionKey): "([^"]+)".*/\2/' \ | sort -u) # Base64 keeps the comparison line-oriented even when an interface key itself diff --git a/Sources/FastConvert/ConverterStore.swift b/Sources/FastConvert/ConverterStore.swift index fef052b..5568c91 100644 --- a/Sources/FastConvert/ConverterStore.swift +++ b/Sources/FastConvert/ConverterStore.swift @@ -98,10 +98,14 @@ enum OutputResolution: String, CaseIterable, Identifiable, Sendable { case .p480: 480 } } - var requiresReencode: Bool { targetHeight != nil } + func requiresReencode(sourceHeight: Int?) -> Bool { + guard let targetHeight else { return false } + return targetHeight != sourceHeight + } - func filter(sourceHeight: Int?, quality: ConversionQuality, useVideoToolbox: Bool) -> String? { + func filter(sourceHeight: Int?, quality: ConversionQuality, useVideoToolbox: Bool, canSkipUnchangedSize: Bool = false) -> String? { guard let targetHeight else { return nil } + if canSkipUnchangedSize && targetHeight == sourceHeight { return nil } if useVideoToolbox { return "scale_vt=w=-2:h=\(targetHeight)" } // Upscaling cannot recover missing detail. Bicubic is visually stable @@ -159,6 +163,7 @@ enum ConversionState: Equatable { enum ConversionPhase: Equatable, Sendable { case inspecting case directRemux + case audioTranscoding case hardwareTranscoding case softwareTranscoding case verifying @@ -167,6 +172,7 @@ enum ConversionPhase: Equatable, Sendable { switch self { case .inspecting: L10n.text("正在检测素材") case .directRemux: L10n.text("正在快速直封装") + case .audioTranscoding: L10n.text("视频直接复制,音频转为 AAC") case .hardwareTranscoding: L10n.text("正在硬件转码") case .softwareTranscoding: L10n.text("正在兼容转码") case .verifying: L10n.text("正在验证输出") @@ -179,10 +185,12 @@ enum ConversionPhase: Equatable, Sendable { /// file name, so it is safe to copy into a support report only after the user /// chooses to do so. struct ConversionFailure: Equatable, Sendable { - let reason: String - let solution: String + let reasonKey: String + let solutionKey: String let diagnostic: String? + var reason: String { L10n.text(reasonKey) } + var solution: String { L10n.text(solutionKey) } var legacyMessage: String { reason } } @@ -190,11 +198,37 @@ struct ConversionFailure: Equatable, Sendable { /// This prevents the current first-video/first-audio mapping from silently /// surprising users, and gives the UI one value to present before start. struct MediaHandlingNotice: Equatable, Sendable { - let summary: String - let warnings: [String] + enum Issue: Equatable, Sendable { + case extraVideo(Int) + case extraAudio(Int) + case subtitles(Int) + case chapters(Int) + case hdrReencoding + case nonstandardRotation + case unconfirmed + + var warning: String { + switch self { + case .extraVideo(let count): L10n.format("检测到 %d 路视频;仅保留第一路。", count) + case .extraAudio(let count): L10n.format("检测到 %d 路音频;仅保留第一路。", count) + case .subtitles(let count): L10n.format("检测到 %d 条字幕;输出不保留字幕。", count) + case .chapters(let count): L10n.format("检测到 %d 个章节;输出不保留章节。", count) + case .hdrReencoding: L10n.format("HDR 将重新编码,色彩或动态范围可能改变;如需保留,请保持原尺寸并使用「%@」。", ConversionPreset.remux.title) + case .nonstandardRotation: L10n.text("非标准旋转角度将重绘画面;请核对输出方向与边缘。") + case .unconfirmed: L10n.text("请确认文件可播放后重试;检测完成前不会开始转换。") + } + } + } + + let issues: [Issue] + + // Persist facts, not translated snapshots: completed rows must follow a + // later language switch just like live status and editor controls do. + var summary: String { issues.contains(.unconfirmed) ? L10n.text("素材信息尚未确认") : "" } + var warnings: [String] { issues.map(\.warning) } var detail: String { - ([summary] + warnings).joined(separator: "\n") + ([summary] + warnings).filter { !$0.isEmpty }.joined(separator: "\n") } } @@ -335,6 +369,29 @@ enum VideoRotation: String, CaseIterable, Equatable, Sendable { } } +/// Dimensions after FFmpeg's automatic orientation correction. Encoded dimensions +/// remain separate because native enhancement and compatible merge use them. +struct VideoGeometry: Equatable, Sendable { + let width: Int + let height: Int + let hasSquarePixels: Bool + + var canSkipUnchangedSize: Bool { + hasSquarePixels && width.isMultiple(of: 2) && height.isMultiple(of: 2) + } + + static func displayed(width: Int?, height: Int?, sampleAspectRatio: String?, rotation: Double?) -> VideoGeometry? { + guard let width, let height, width > 0, height > 0 else { return nil } + let angle = rotation ?? 0 + guard angle.isFinite else { return nil } + let quarterTurns = (angle / 90).rounded() + guard abs(angle - quarterTurns * 90) < 0.01 else { return nil } + let swapsDimensions = abs(quarterTurns.truncatingRemainder(dividingBy: 2)) == 1 + let squarePixels = sampleAspectRatio == "1:1" || sampleAspectRatio == "1/1" + return VideoGeometry(width: swapsDimensions ? height : width, height: swapsDimensions ? width : height, hasSquarePixels: squarePixels) + } +} + struct VideoInfo: Sendable { let duration: Double?, videoRate: Int?, audioRate: Int?, width: Int?, height: Int?, hasVideo: Bool let videoCodec: String? @@ -347,6 +404,36 @@ struct VideoInfo: Sendable { let subtitleTrackCount: Int let colorTransfer: String? let probeSucceeded: Bool + var displayGeometry: VideoGeometry? = nil + var timeBase: String? = nil + var sampleAspectRatio: String? = nil + var displayRotation: Double? = nil + var videoTrackCount = 0 + var chapterCount = 0 + + func geometry(crop: CropRect?, rotation: VideoRotation?) -> VideoGeometry? { + let geometry: VideoGeometry + if let crop, crop.width > 0, crop.height > 0 { + geometry = VideoGeometry(width: crop.width, height: crop.height, hasSquarePixels: displayGeometry?.hasSquarePixels == true) + } else if let displayGeometry { + geometry = displayGeometry + } else { + return nil + } + guard rotation?.swapsDimensions == true else { return geometry } + return VideoGeometry(width: geometry.height, height: geometry.width, hasSquarePixels: geometry.hasSquarePixels) + } + + /// Match the demuxer's clock so VFR timestamps are not rounded to the + /// nominal frame-rate clock. A literal rational also supports older FFmpeg + /// versions that do not accept the newer symbolic `demux` option. + var encodingTimeBase: String? { + guard let timeBase else { return nil } + let fields = timeBase.split(separator: "/", omittingEmptySubsequences: false) + guard fields.count == 2, let numerator = Int32(fields[0]), let denominator = Int32(fields[1]), + numerator > 0, denominator > 0 else { return nil } + return "\(numerator)/\(denominator)" + } var isHDR: Bool { ["smpte2084", "arib-std-b67"].contains(colorTransfer?.lowercased() ?? "") @@ -355,12 +442,24 @@ struct VideoInfo: Sendable { /// These elementary streams can be placed in MP4 without loss. Restricting /// audio to AAC keeps the automatic path broadly compatible with players. var canDirectRemuxToMP4: Bool { - ["h264", "hevc"].contains(videoCodec?.lowercased() ?? "") - && (audioCodec == nil || audioCodec?.lowercased() == "aac") + canCopyVideoToMP4 && (audioCodec == nil || audioCodec?.lowercased() == "aac") } + var canCopyVideoToMP4: Bool { ["h264", "hevc"].contains(videoCodec?.lowercased() ?? "") } fileprivate var mergeSignature: MergeSignature? { - guard let videoCodec, let width, let height, let frameRate, let pixelFormat else { return nil } - return MergeSignature(videoCodec: videoCodec.lowercased(), width: width, height: height, frameRate: frameRate, pixelFormat: pixelFormat, audioCodec: audioCodec?.lowercased(), audioSampleRate: audioSampleRate, audioChannels: audioChannels) + guard let videoCodec, let width, let height, let frameRate, let pixelFormat, + let timeBase = encodingTimeBase else { return nil } + 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) + } + fileprivate var mergePixelAspectRatio: Double { + guard let sampleAspectRatio else { return 1 } + let fields = sampleAspectRatio.split(whereSeparator: { $0 == ":" || $0 == "/" }) + guard fields.count == 2, let numerator = Double(fields[0]), let denominator = Double(fields[1]), + numerator > 0, denominator > 0 else { return 1 } + let value = numerator / denominator + return value.isFinite ? value : 1 } var fallbackRate: Int { switch (width ?? 1920) * (height ?? 1080) { @@ -392,6 +491,9 @@ private struct MergeSignature: Hashable, Sendable { let height: Int let frameRate: String let pixelFormat: String + let timeBase: String + let sampleAspectRatio: String? + let displayRotation: Double let audioCodec: String? let audioSampleRate: String? let audioChannels: Int? @@ -405,71 +507,83 @@ private struct CompatibleMergeTarget: Sendable { var summary: String { "\(width) × \(height)" } } -private struct ProbeResponse: Decodable, Sendable { let streams: [ProbeStream]; let format: ProbeFormat? } +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 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 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 } } +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" } } -private final class ErrorBuffer: @unchecked Sendable { - private let lock = NSLock() - private var value = "" - func append(_ text: String) { lock.lock(); value = String((value + text).suffix(4_000)); lock.unlock() } - 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 } +/// Only retry a full transcode when FFmpeg clearly identifies a VideoToolbox +/// component that can be replaced. Generic decode, mux, timeout, and I/O errors +/// are deliberately not retried: replaying a long file would waste time and +/// would not address the underlying failure. +enum FFmpegHardwareRetryHint: Equatable { + case cpuDecodeAndFilter + case softwareEncoder } -/// `-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 { - private let lock = NSLock() - private var lastActivity = Date() - private var timeoutMessage: String? - - func touch() { - lock.lock() - lastActivity = Date() - lock.unlock() - } - - func inactivity() -> TimeInterval { - lock.lock() - defer { lock.unlock() } - return Date().timeIntervalSince(lastActivity) - } +enum FFmpegHardwareRetryClassifier { + static func hint(stderr: String, usedHardwarePipeline: Bool, usedHardwareEncoder: Bool) -> FFmpegHardwareRetryHint? { + guard usedHardwarePipeline || usedHardwareEncoder else { return nil } + let lines = stderr.lowercased().components(separatedBy: .newlines) + let terminalFailure = [ + "no space left", "disk full", "permission denied", "operation not permitted", + "could not write header", "error opening output", "error writing output", "muxer", + "invalid data found when processing input", "moov atom not found", "broken pipe", + "out of memory", "cannot allocate memory", "not enough memory", "timed out", "timeout", + "terminated by signal", "received signal" + ].contains { marker in lines.contains(where: { $0.contains(marker) }) } + guard !terminalFailure else { return nil } + + if usedHardwarePipeline { + let filterMarkers = ["scale_vt", "transpose_vt"] + let filterFailurePhrases = [ + "no such filter", "not found", "not supported", "failed", "error", + "impossible to convert", "cannot convert" + ] + if lines.contains(where: { line in + filterMarkers.contains(where: line.contains) && filterFailurePhrases.contains(where: line.contains) + }) { + return .cpuDecodeAndFilter + } - func markTimedOut(_ message: String) -> Bool { - lock.lock() - defer { lock.unlock() } - guard timeoutMessage == nil else { return false } - timeoutMessage = message - return true - } + let decoderMarkers = ["videotoolbox_vld", "vtdecompressionsession", "kvtvideodecoder", "hwaccel", "hwcontext"] + let decoderFailurePhrases = [ + "failed to create", "device setup failed", "failed to initialize", "failed to initialise", + "failed to get format", "no device available", "no hardware device", "unsupported pixel format", + "error creating device", "decoder initialization failed", "decoder initialisation failed" + ] + if lines.contains(where: { line in + (decoderMarkers.contains(where: line.contains) || line.contains("device setup failed for decoder")) + && decoderFailurePhrases.contains(where: line.contains) + }) { + return .cpuDecodeAndFilter + } + } - func timeout() -> String? { - lock.lock() - defer { lock.unlock() } - return timeoutMessage + guard usedHardwareEncoder else { return nil } + let encoderMarkers = ["h264_videotoolbox", "hevc_videotoolbox", "vtcompressionsession", "kvtvideoencoder"] + let encoderFailurePhrases = [ + "unknown encoder", "failed", "failure", "error", "not available", + "could not open", "cannot create", "unable to open" + ] + guard lines.contains(where: { line in + encoderMarkers.contains(where: line.contains) && encoderFailurePhrases.contains(where: line.contains) + }) else { return nil } + return .softwareEncoder } } @@ -486,17 +600,21 @@ final class ConverterStore: ObservableObject { @Published var statusText = "" @Published var showFFmpegSetup = false @Published private(set) var isMerging = false + @Published private(set) var isVerifyingMerge = false @Published var showMergeNotice = false @Published var showCompatibleMergePrompt = false @Published private(set) var mergeNotice = "" @Published private(set) var mergeOutput: URL? @Published private(set) var mergeProgress: Double? + private let ffmpegOverride: URL? private var ffmpegURL: URL? - private var activeProcesses: [UUID: Process] = [:] + private var activeProcesses: [UUID: FFmpegProcessRunner] = [:] private var activeTemporaryOutputs: [UUID: URL] = [:] private var cancellationRequested = false - private var queueIDs: [UUID] = [] + // Queue order is held by convertQueue's snapshot; membership checks happen + // on every progress update and must stay constant-time for large batches. + private var queueIDs: Set = [] private var progressTimes: [UUID: Double] = [:] private var progressBuffers: [UUID: String] = [:] private var progressGeneration: [UUID: UUID] = [:] @@ -505,6 +623,7 @@ final class ConverterStore: ObservableObject { private var mergeDurations: [Double?] = [] private var mergeProgressBuffer = "" private var mergeProgressTime: Double? + private var mergeProgressGeneration: UUID? private var compatibleMergeIDs: [UUID] = [] private var isCompatibleMerging = false /// FFmpeg emits a machine-readable progress packet roughly twice a second @@ -513,6 +632,14 @@ final class ConverterStore: ObservableObject { /// broken inputs, or wedged hardware sessions. private let ffmpegNoProgressTimeout: TimeInterval = 90 private let ffmpegTerminationGrace: UInt64 = 3_000_000_000 + private var processConfiguration: FFmpegProcessRunner.Configuration { + FFmpegProcessRunner.Configuration( + inactivityTimeout: ffmpegNoProgressTimeout, + terminationGrace: Double(ffmpegTerminationGrace) / 1_000_000_000, + activityPolicy: .advancingFFmpegProgress, + maximumOutputBytes: 0 + ) + } private let presentationQueue: OperationQueue = { let queue = OperationQueue() queue.name = "com.ixiehao.mp4flow.media-inspection" @@ -521,6 +648,13 @@ final class ConverterStore: ObservableObject { return queue }() + /// A local tool override lets integration tests exercise the real queue + /// through a controlled FFmpeg executable; normal app launches use the + /// existing installation search below. + init(ffmpegOverride: URL? = nil) { + self.ffmpegOverride = ffmpegOverride + } + var hasWaiting: Bool { items.contains { if case .waiting = $0.state { return true }; return false } } var hasCompleted: Bool { items.contains { if case .complete = $0.state { return true }; return false } } var hasIncomplete: Bool { items.contains { if case .failed = $0.state { return true }; if case .cancelled = $0.state { return true }; return false } } @@ -528,6 +662,7 @@ final class ConverterStore: ObservableObject { var mergeDisplayCount: Int { isMerging ? mergeItemIDs.count : mergeCandidateCount } var canMerge: Bool { !isBusy && mergeCandidateCount >= 2 } var isBusy: Bool { isRunning || isMerging } + var isCancelling: Bool { isBusy && cancellationRequested } var mergeModeTitle: String { isCompatibleMerging ? L10n.text("兼容合并") : L10n.text("无损合并") } var isFFmpegAvailable: Bool { locateFFmpeg() != nil } /// Only items still in the queue are counted. Removing completed rows must @@ -610,7 +745,7 @@ final class ConverterStore: ObservableObject { if case .complete = $0.state { return true } return false } - queueIDs.removeAll { completedIDs.contains($0) } + queueIDs.subtract(completedIDs) if isRunning { refreshProgress(queueIDs.count) } } /// Removes queue records only. Source files and generated outputs remain untouched. @@ -645,7 +780,7 @@ final class ConverterStore: ObservableObject { } let pendingIDs = items.compactMap { if case .waiting = $0.state { return $0.id }; return nil } guard !pendingIDs.isEmpty else { return } - ffmpegURL = binary; cancellationRequested = false; queueIDs = pendingIDs; isRunning = true; overallProgress = 0 + ffmpegURL = binary; cancellationRequested = false; isVerifyingMerge = false; queueIDs = Set(pendingIDs); isRunning = true; overallProgress = 0 // Thumbnail extraction is useful for review, but it decodes video and // competes with FFmpeg for CPU, I/O, and hardware-decoder resources. presentationQueue.isSuspended = true @@ -660,6 +795,9 @@ final class ConverterStore: ObservableObject { let sources = items.compactMap { item -> URL? in if case .waiting = item.state, item.trimRange == nil, item.rotation == nil, item.crop == nil { return item.source }; return nil } mergeItemIDs = items.compactMap { item -> UUID? in if case .waiting = item.state, item.trimRange == nil, item.rotation == nil, item.crop == nil { return item.id }; return nil } for index in items.indices where mergeItemIDs.contains(items[index].id) { items[index].state = .merging(nil) } + cancellationRequested = false + isVerifyingMerge = false + presentationQueue.isSuspended = true isMerging = true mergeProgress = nil mergeOutput = nil @@ -680,6 +818,9 @@ final class ConverterStore: ObservableObject { return false } guard candidates.count >= 2 else { return } + cancellationRequested = false + isVerifyingMerge = false + presentationQueue.isSuspended = true mergeItemIDs = candidates.map(\.id) for index in items.indices where mergeItemIDs.contains(items[index].id) { items[index].state = .merging(nil) } isCompatibleMerging = true @@ -718,6 +859,7 @@ final class ConverterStore: ObservableObject { func remove(_ item: ConversionItem) { guard canRemove(item) else { return } items.removeAll { $0.id == item.id } + queueIDs.remove(item.id) if isRunning { refreshProgress(queueIDs.count) } } @@ -728,19 +870,10 @@ final class ConverterStore: ObservableObject { } func cancel() { - guard isRunning else { return } + guard isBusy else { return } cancellationRequested = true statusText = L10n.text("正在停止转换并清理临时文件") - let processes = Array(activeProcesses.values) - processes.filter(\.isRunning).forEach { $0.terminate() } - Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: self.ffmpegTerminationGrace) - guard self.isRunning, self.cancellationRequested else { return } - self.activeProcesses.values.filter(\.isRunning).forEach { process in - _ = Darwin.kill(process.processIdentifier, SIGKILL) - } - } + activeProcesses.values.forEach { $0.cancel() } } func revealMergeOutput() { @@ -802,7 +935,14 @@ final class ConverterStore: ObservableObject { return } var infos: [VideoInfo] = [] - for source in sources { infos.append(await probe(source, ffmpegURL: ffmpegURL)) } + for source in sources { + guard !cancellationRequested else { break } + infos.append(await probe(source, ffmpegURL: ffmpegURL)) + } + guard !cancellationRequested else { + finishMerge(message: L10n.text("合并已取消。"), output: nil) + return + } guard infos.allSatisfy({ $0.canDirectRemuxToMP4 }), let signature = infos.first?.mergeSignature, infos.dropFirst().allSatisfy({ $0.mergeSignature == signature }) else { @@ -827,8 +967,16 @@ final class ConverterStore: ObservableObject { return } - let result = await runMergeFFmpeg(ffmpegURL: ffmpegURL, listURL: listURL, output: temporary) + var result = await runMergeFFmpeg(ffmpegURL: ffmpegURL, listURL: listURL, output: temporary) try? FileManager.default.removeItem(at: listURL) + if case .success = result, !cancellationRequested { + isVerifyingMerge = true + statusText = L10n.text("正在验证输出") + if let failure = await verifyOutput(temporary, ffmpegURL: ffmpegURL) { + result = .failure(failure.legacyMessage) + } + } + if cancellationRequested { result = .cancelled } switch result { case .success: do { @@ -838,9 +986,10 @@ final class ConverterStore: ObservableObject { try? FileManager.default.removeItem(at: temporary) finishMerge(message: L10n.format("无法无损合并:%@", error.localizedDescription), output: nil) } - case .failure(let message): + case .failure(let message), .retryableFailure(let message, _): try? FileManager.default.removeItem(at: temporary) - finishMerge(message: L10n.format("无法无损合并:%@", message), output: nil) + let displayMessage = message.hasPrefix("MP4Flow:") ? Self.failure(from: message).legacyMessage : message + finishMerge(message: L10n.format("无法无损合并:%@", displayMessage), output: nil) case .cancelled: try? FileManager.default.removeItem(at: temporary) finishMerge(message: L10n.text("合并已取消。"), output: nil) @@ -853,6 +1002,7 @@ final class ConverterStore: ObservableObject { if case .merging = items[index].state { items[index].state = .waiting } } isMerging = false + isVerifyingMerge = false mergeNotice = message mergeOutput = output mergeProgress = nil @@ -860,8 +1010,10 @@ final class ConverterStore: ObservableObject { mergeDurations.removeAll() mergeProgressBuffer = "" mergeProgressTime = nil + mergeProgressGeneration = nil compatibleMergeIDs.removeAll() isCompatibleMerging = false + presentationQueue.isSuspended = false statusText = output == nil ? L10n.format("%@未完成", completedMode) : L10n.format("%@完成", completedMode) showMergeNotice = true } @@ -874,51 +1026,32 @@ final class ConverterStore: ObservableObject { mergeProgress = nil mergeProgressBuffer = "" mergeProgressTime = nil + mergeProgressGeneration = nil isMerging = false - statusText = L10n.text("片段参数不同,可使用兼容合并。") + isVerifyingMerge = false + presentationQueue.isSuspended = false + statusText = L10n.text("请先统一规格转码,再合并;也可使用兼容合并。") showCompatibleMergePrompt = true } private func runMergeFFmpeg(ffmpegURL: URL, listURL: URL, output: URL) async -> ProcessResult { - let process = Process(), progress = Pipe(), errors = Pipe(), stderr = ErrorBuffer() - let store = self - process.executableURL = ffmpegURL - process.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] - process.standardOutput = progress - process.standardError = errors - progress.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - Task { @MainActor [store] in store.consumeMergeProgress(String(decoding: data, as: UTF8.self)) } - } - errors.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - 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 - if finished.terminationStatus == 0 { continuation.resume(returning: .success) } - else { continuation.resume(returning: .failure(store.friendlyMergeError(stderr.read(), code: finished.terminationStatus))) } - } - } - do { try process.run() } - catch { - progress.fileHandleForReading.readabilityHandler = nil - errors.fileHandleForReading.readabilityHandler = nil - continuation.resume(returning: .failure("无法启动 FFmpeg:\(error.localizedDescription)")) - } - } + 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], + usesHardwareEncoder: false + ) } private func mergeCompatible(sources: [URL], ffmpegURL: URL) async { var infos: [VideoInfo] = [] - for source in sources { infos.append(await probe(source, ffmpegURL: ffmpegURL)) } + for source in sources { + guard !cancellationRequested else { break } + infos.append(await probe(source, ffmpegURL: ffmpegURL)) + } + guard !cancellationRequested else { + finishMerge(message: L10n.text("兼容合并已取消。"), output: nil) + return + } guard infos.allSatisfy(\.hasVideo) else { finishMerge(message: L10n.text("兼容合并失败:存在无法读取的视频流。"), output: nil) return @@ -934,11 +1067,19 @@ final class ConverterStore: ObservableObject { // filter graph. The encoder can still be hardware accelerated; when a // VideoToolbox session cannot be created, retry once with libx264 and // the exact same target size, rate limits, and AAC settings. - if case .failure = result, !cancellationRequested { + if case .retryableFailure(_, .softwareEncoder) = result, !cancellationRequested { statusText = L10n.text("硬件编码不可用,正在使用兼容编码重试") resetCompatibleMergeProgress() result = await runCompatibleMergeFFmpeg(ffmpegURL: ffmpegURL, sources: sources, infos: infos, target: target, output: temporary, preferHardwareEncoder: false) } + if case .success = result, !cancellationRequested { + isVerifyingMerge = true + statusText = L10n.text("正在验证输出") + if let failure = await verifyOutput(temporary, ffmpegURL: ffmpegURL) { + result = .failure(failure.legacyMessage) + } + } + if cancellationRequested { result = .cancelled } switch result { case .success: do { @@ -948,9 +1089,10 @@ final class ConverterStore: ObservableObject { try? FileManager.default.removeItem(at: temporary) finishMerge(message: L10n.format("兼容合并失败:%@", error.localizedDescription), output: nil) } - case .failure(let message): + case .failure(let message), .retryableFailure(let message, _): try? FileManager.default.removeItem(at: temporary) - finishMerge(message: L10n.format("兼容合并失败:%@", message), output: nil) + let displayMessage = message.hasPrefix("MP4Flow:") ? Self.failure(from: message).legacyMessage : message + finishMerge(message: L10n.format("兼容合并失败:%@", displayMessage), output: nil) case .cancelled: try? FileManager.default.removeItem(at: temporary) finishMerge(message: L10n.text("兼容合并已取消。"), output: nil) @@ -964,55 +1106,58 @@ final class ConverterStore: ObservableObject { } private func runCompatibleMergeFFmpeg(ffmpegURL: URL, sources: [URL], infos: [VideoInfo], target: CompatibleMergeTarget, output: URL, preferHardwareEncoder: Bool = true) async -> ProcessResult { - let process = Process(), progress = Pipe(), errors = Pipe(), stderr = ErrorBuffer() - let store = self - process.executableURL = ffmpegURL - process.arguments = compatibleMergeArguments(sources: sources, infos: infos, target: target, output: output, preferHardwareEncoder: preferHardwareEncoder) - process.standardOutput = progress - process.standardError = errors - progress.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - Task { @MainActor [store] in store.consumeMergeProgress(String(decoding: data, as: UTF8.self)) } - } - errors.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - 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 - if finished.terminationStatus == 0 { continuation.resume(returning: .success) } - else { continuation.resume(returning: .failure(store.friendlyMergeError(stderr.read(), code: finished.terminationStatus))) } - } - } - do { try process.run() } - catch { - progress.fileHandleForReading.readabilityHandler = nil - errors.fileHandleForReading.readabilityHandler = nil - continuation.resume(returning: .failure("无法启动 FFmpeg:\(error.localizedDescription)")) + await runMergeProcess( + ffmpegURL: ffmpegURL, + arguments: compatibleMergeArguments(sources: sources, infos: infos, target: target, output: output, preferHardwareEncoder: preferHardwareEncoder), + usesHardwareEncoder: preferHardwareEncoder + ) + } + + private func runMergeProcess(ffmpegURL: URL, arguments: [String], usesHardwareEncoder: Bool) async -> ProcessResult { + guard !cancellationRequested else { return .cancelled } + let id = UUID(), generation = UUID() + let runner = FFmpegProcessRunner(executable: ffmpegURL, arguments: arguments, configuration: processConfiguration) + activeProcesses[id] = runner + mergeProgressGeneration = generation + defer { + activeProcesses.removeValue(forKey: id) + if mergeProgressGeneration == generation { mergeProgressGeneration = nil } + } + let result = await runner.run { [weak self] data in + Task { @MainActor [weak self] in + guard let self, self.isMerging, !self.cancellationRequested, + self.mergeProgressGeneration == generation else { return } + self.consumeMergeProgress(String(decoding: data, as: UTF8.self)) } } + if cancellationRequested || result.wasCancelled { return .cancelled } + if result.timedOut { return .failure("MP4Flow:stalled") } + if result.launchError != nil { return .failure("MP4Flow:launch-failed") } + if result.succeeded { return .success } + let message = friendlyMergeError(result.standardError, code: result.terminationStatus ?? -1) + if let hint = FFmpegHardwareRetryClassifier.hint(stderr: result.standardError, usedHardwarePipeline: false, usedHardwareEncoder: usesHardwareEncoder) { + return .retryableFailure(message, hint) + } + return .failure(message) } - /// Normalize to the largest source frame rather than to the first file in + /// Normalize to the largest displayed square-pixel frame rather than to the first file in /// queue. Smaller clips are scaled up proportionally; differing aspect /// ratios are centered on a black canvas, never stretched or cropped. private func compatibleMergeTarget(_ infos: [VideoInfo]) -> CompatibleMergeTarget { - let largest = infos.max { lhs, rhs in - let lhsPixels = max(lhs.width ?? 0, 0) * max(lhs.height ?? 0, 0) - let rhsPixels = max(rhs.width ?? 0, 0) * max(rhs.height ?? 0, 0) - return lhsPixels < rhsPixels - } ?? infos[0] + let displayed = infos.map { info -> (info: VideoInfo, width: Int, height: Int) in + let width = info.width ?? 1920, height = info.height ?? 1080 + let scaledWidth = Double(width) * info.mergePixelAspectRatio + let normalizedWidth = scaledWidth.isFinite && scaledWidth >= 2 && scaledWidth < Double(Int.max) + ? even(Int(scaledWidth)) : even(width) + let geometry = VideoGeometry.displayed(width: normalizedWidth, height: height, sampleAspectRatio: "1:1", rotation: info.displayRotation) + return (info, even(geometry?.width ?? normalizedWidth), even(geometry?.height ?? height)) + } + let largest = displayed.max { Double($0.width) * Double($0.height) < Double($1.width) * Double($1.height) }! return CompatibleMergeTarget( - width: even(largest.width ?? 1920), - height: even(largest.height ?? 1080), - frameRate: largest.frameRate ?? "30" + width: largest.width, + height: largest.height, + frameRate: largest.info.frameRate ?? "30" ) } @@ -1022,7 +1167,10 @@ final class ConverterStore: ObservableObject { var filters: [String] = [] for (index, info) in infos.enumerated() { - filters.append("[\(index):v:0]scale=\(target.width):\(target.height):force_original_aspect_ratio=decrease,pad=\(target.width):\(target.height):(ow-iw)/2:(oh-ih)/2:color=black,fps=\(target.frameRate),setsar=1,format=yuv420p[v\(index)]") + // 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)]") if info.audioCodec != nil { filters.append("[\(index):a:0]aresample=48000,aformat=channel_layouts=stereo[a\(index)]") } else { @@ -1073,12 +1221,14 @@ final class ConverterStore: ObservableObject { let total = knownDurations.reduce(0, +) guard total > 0 else { mergeProgress = nil; return } let clampedSeconds = min(max(seconds, 0), total) - mergeProgress = clampedSeconds / total + // FFmpeg finishing its packets is not publication. Keep the final + // fraction reserved for validation and the atomic output move. + mergeProgress = min(clampedSeconds / total, 0.999) var elapsed = 0.0 for (offset, id) in mergeItemIDs.enumerated() { guard let index = items.firstIndex(where: { $0.id == id }), let duration = mergeDurations[offset] else { continue } let localProgress = (clampedSeconds - elapsed) / duration - let displayedProgress: Double? = localProgress < 0 ? nil : min(max(localProgress, 0), 1) + let displayedProgress: Double? = localProgress < 0 ? nil : min(max(localProgress, 0), 0.999) items[index].state = .merging(displayedProgress) elapsed += duration } @@ -1141,16 +1291,16 @@ final class ConverterStore: ObservableObject { } guard info.probeSucceeded else { markFailed(id, failure: ConversionFailure( - reason: L10n.text("无法读取此视频的媒体数据"), - solution: L10n.text("请确认文件可播放,或使用「智能转换」生成新文件后重试。"), + reasonKey: "无法读取此视频的媒体数据", + solutionKey: "请确认文件可播放,或使用「智能转换」生成新文件后重试。", diagnostic: "probe:unavailable" ), count: count) return } guard info.hasVideo else { markFailed(id, failure: ConversionFailure( - reason: L10n.text("未检测到可用视频流"), - solution: L10n.text("请确认文件可播放,或重新导出后再试。"), + reasonKey: "未检测到可用视频流", + solutionKey: "请确认文件可播放,或重新导出后再试。", diagnostic: "probe:no-video" ), count: count) return @@ -1160,10 +1310,19 @@ final class ConverterStore: ObservableObject { // selected conversion preset. This is the same fast path as “极速直封装”. // Unsupported streams fall back to the user's normal hardware/software // conversion choice so the output remains a playable MP4. + let editedGeometry = info.geometry(crop: crop, rotation: rotation) + let unchangedHeight = editedGeometry?.canSkipUnchangedSize == true ? editedGeometry?.height : nil + let resizeRequiresEncoding = resolution.requiresReencode(sourceHeight: unchangedHeight) + // In automatic mode an incompatible audio codec should not force a + // perfectly compatible video through a full decode/encode cycle. + // Restrict this route to complete, unedited source timelines. + let copyVideoAndEncodeAudio = preset == .smartMP4 + && trim == nil && rotation == nil && crop == nil && !resizeRequiresEncoding + && info.canCopyVideoToMP4 && info.audioCodec != nil && info.audioCodec?.lowercased() != "aac" let effectivePreset: ConversionPreset if preset == .smartEnhanceUpscale { effectivePreset = preset - } else if rotation != nil || crop != nil || resolution.requiresReencode { + } else if rotation != nil || crop != nil || resizeRequiresEncoding { // 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. @@ -1171,13 +1330,13 @@ final class ConverterStore: ObservableObject { } else if trim != nil, info.canDirectRemuxToMP4 { effectivePreset = .remux } else if preset == .smartMP4 { - effectivePreset = info.canDirectRemuxToMP4 ? .remux : .h264Hardware + effectivePreset = info.canDirectRemuxToMP4 || copyVideoAndEncodeAudio ? .remux : .h264Hardware } else { effectivePreset = preset } items[index].failure = nil - items[index].mediaHandling = mediaHandlingNotice(for: info, preset: effectivePreset) - setPhase(phase(for: effectivePreset, preferHardwareEncoder: true), for: id, progress: info.duration == nil ? nil : 0, count: count, detail: outputSummary(info, preset: effectivePreset, quality: quality, resolution: resolution)) + items[index].mediaHandling = Self.mediaHandlingNotice(for: info, preset: effectivePreset) + setPhase(copyVideoAndEncodeAudio ? .audioTranscoding : phase(for: effectivePreset, preferHardwareEncoder: true), for: id, progress: info.duration == nil ? nil : 0, count: count, detail: copyVideoAndEncodeAudio ? nil : outputSummary(info, preset: effectivePreset, quality: quality, resolution: resolution, sourceHeight: editedGeometry?.height)) let final = uniqueOutput(for: source, trim: trim, rotation: rotation, crop: crop) let temporary = final.deletingLastPathComponent().appendingPathComponent(".MP4Flow-\(UUID().uuidString).partial.mp4") activeTemporaryOutputs[id] = temporary @@ -1186,16 +1345,16 @@ final class ConverterStore: ObservableObject { if effectivePreset == .smartEnhanceUpscale { guard rotation == nil, crop == nil else { markFailed(id, failure: ConversionFailure( - reason: L10n.text("智能增强不支持裁切或旋转"), - solution: L10n.text("请先使用普通转换,再剪辑或裁切。"), + reasonKey: "智能增强不支持裁切或旋转", + solutionKey: "请取消裁切和旋转后再增强;或先完成普通编辑,再添加新文件增强。", diagnostic: "enhance:unsupported-edit" ), count: count) return } guard #available(macOS 27.0, *), let plan = SmartEnhancePlan.make(info: info, target: smartEnhanceTarget) else { markFailed(id, failure: ConversionFailure( - reason: L10n.text("此视频暂不支持智能增强"), - solution: L10n.text("请使用「智能转换」先转换为新文件,然后添加新文件重试。"), + reasonKey: "此视频暂不支持智能增强", + solutionKey: "请使用「智能转换」先转换为新文件,然后添加新文件重试。", diagnostic: "enhance:unsupported-source" ), count: count) return @@ -1204,24 +1363,21 @@ final class ConverterStore: ObservableObject { statusText = L10n.format("智能增强放大至 %@", plan.outputSummary) result = await runSmartEnhance(source: source, output: temporary, info: info, plan: plan, trim: trim, quality: quality, id: id, count: count) } else { - result = await runFFmpeg(ffmpegURL: ffmpegURL, source: source, output: temporary, info: info, preset: effectivePreset, quality: quality, resolution: resolution, trim: trim, rotation: rotation, crop: crop, id: id, count: count) - } - // Keep the fast VideoToolbox path as the default, but never make one - // unavailable hardware session fail an otherwise healthy batch. The - // first retry only moves decode/filtering back to the CPU; if the - // hardware encoder itself is unavailable, the final retry uses libx264 - // at the same target bitrate. Retries are per-item and bounded. - if case .failure = result, + result = await runFFmpeg(ffmpegURL: ffmpegURL, source: source, output: temporary, info: info, preset: effectivePreset, quality: quality, resolution: resolution, trim: trim, rotation: rotation, crop: crop, id: id, count: count, copyVideoAndEncodeAudio: copyVideoAndEncodeAudio) + } + // Keep the fast VideoToolbox path as the default. Retry only when the + // stderr allowlist identifies a specific VideoToolbox component: a + // decoder/filter failure gets one CPU-pipeline attempt, while an + // encoder failure goes directly to libx264. Generic media, mux, I/O, + // and timeout failures never replay the whole video automatically. + if case .retryableFailure(_, .cpuDecodeAndFilter) = result, !cancellationRequested, - !isTerminalFFmpegFailure(result), - effectivePreset != .smartEnhanceUpscale, - canUseVideoToolboxPipeline(info: info, preset: effectivePreset, crop: crop) { + effectivePreset != .smartEnhanceUpscale { setPhase(.softwareTranscoding, for: id, progress: nil, count: count, detail: L10n.text("硬件处理失败,正在使用兼容方式重试")) result = await runFFmpeg(ffmpegURL: ffmpegURL, source: source, output: temporary, info: info, preset: effectivePreset, quality: quality, resolution: resolution, trim: trim, rotation: rotation, crop: crop, id: id, count: count, preferHardwarePipeline: false) } - if case .failure = result, + if case .retryableFailure(_, .softwareEncoder) = result, !cancellationRequested, - !isTerminalFFmpegFailure(result), effectivePreset != .smartEnhanceUpscale, usesVideoToolboxEncoder(effectivePreset) { setPhase(.softwareTranscoding, for: id, progress: nil, count: count, detail: L10n.text("硬件编码不可用,正在使用兼容编码重试")) @@ -1249,8 +1405,8 @@ final class ConverterStore: ObservableObject { } catch { try? FileManager.default.removeItem(at: temporary) markFailed(id, failure: ConversionFailure( - reason: L10n.text("无法保存输出文件"), - solution: L10n.text("请检查输出文件夹权限和可用空间后重试。"), + reasonKey: "无法保存输出文件", + solutionKey: "请检查输出文件夹权限和可用空间后重试。", diagnostic: "publish:\(error._code)" ), count: count) } @@ -1259,19 +1415,16 @@ final class ConverterStore: ObservableObject { items[current].phase = nil items[current].failure = nil try? FileManager.default.removeItem(at: temporary) - case .failure(let message): + case .failure(let message), .retryableFailure(let message, _): try? FileManager.default.removeItem(at: temporary) let existingFailure = items[current].failure - markFailed(id, failure: existingFailure ?? failure(from: message), count: count) + markFailed(id, failure: existingFailure ?? Self.failure(from: message), count: count) } refreshProgress(count) } - 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 enum ProcessResult { + case success, cancelled, failure(String), retryableFailure(String, FFmpegHardwareRetryHint) } private func shouldRetryProbe(for source: URL) -> Bool { @@ -1302,73 +1455,126 @@ final class ConverterStore: ObservableObject { refreshProgress(count) } - private func mediaHandlingNotice(for info: VideoInfo, preset: ConversionPreset) -> MediaHandlingNotice { - let audioDescription = info.audioCodec == nil - ? L10n.text("未检测到音频流。") - : L10n.text("仅保留第一路视频和第一路音频。") - var warnings = [L10n.text("容器元数据和章节将被移除。")] + /// Queue notices describe changes to this material, not the default policy. + /// Ordinary files need no disclaimer; policy remains in Help/Environment. + nonisolated static func mediaHandlingNotice(for info: VideoInfo, preset: ConversionPreset) -> MediaHandlingNotice? { + guard info.probeSucceeded, info.hasVideo, info.videoTrackCount > 0 else { + return MediaHandlingNotice(issues: [.unconfirmed]) + } + var issues: [MediaHandlingNotice.Issue] = [] + if info.videoTrackCount > 1 { + issues.append(.extraVideo(info.videoTrackCount)) + } if info.audioTrackCount > 1 { - warnings.append(L10n.format("检测到 %d 路音频;其余音轨不会写入输出。", info.audioTrackCount)) + issues.append(.extraAudio(info.audioTrackCount)) } if info.subtitleTrackCount > 0 { - warnings.append(L10n.format("检测到 %d 条字幕;当前输出不会保留字幕。", info.subtitleTrackCount)) + issues.append(.subtitles(info.subtitleTrackCount)) } - if info.isHDR { - warnings.append( - preset == .remux - ? L10n.text("检测到 HDR;视频流将直接封装,完成后仍建议核对播放效果。") - : L10n.text("检测到 HDR;转码后请核对色彩与动态范围。") - ) + if info.chapterCount > 0 { + issues.append(.chapters(info.chapterCount)) } - return MediaHandlingNotice(summary: audioDescription, warnings: warnings) + if info.isHDR, preset != .remux { + issues.append(.hdrReencoding) + } + if preset != .remux, let angle = info.displayRotation, angle.isFinite, + abs(angle / 90 - (angle / 90).rounded()) > 0.0001 { + issues.append(.nonstandardRotation) + } + return issues.isEmpty ? nil : MediaHandlingNotice(issues: issues) } - private func failure(from message: String) -> ConversionFailure { + nonisolated static func failure(from message: String) -> ConversionFailure { + if message == "MP4Flow:stalled" { + return ConversionFailure( + reasonKey: "转换长时间无进度,已安全停止", + solutionKey: "请确认视频可播放、磁盘可用后重试;仍失败请使用「最稳妥转换」。", + diagnostic: "ffmpeg:stalled" + ) + } + if message == "MP4Flow:hardware-encoder" { + return ConversionFailure( + reasonKey: "硬件编码不可用", + solutionKey: "请重试;仍失败请使用「最稳妥转换」。", + diagnostic: "ffmpeg:hardware-encoder" + ) + } + if message == "MP4Flow:software-encoder" { + return ConversionFailure( + reasonKey: "软件编码器不可用", + solutionKey: "请更新或重新安装 FFmpeg,或改用「智能转换」后重试。", + diagnostic: "ffmpeg:software-encoder" + ) + } + if message == "MP4Flow:unreadable-media" { + return ConversionFailure( + reasonKey: "无法读取此视频的媒体数据", + solutionKey: "请确认文件可播放,或使用「智能转换」生成新文件后重试。", + diagnostic: "ffmpeg:unreadable-media" + ) + } + if message == "MP4Flow:container-incompatible" { + return ConversionFailure( + reasonKey: "此视频流无法直接写入 MP4", + solutionKey: "请使用「智能转换」重新编码后重试;仍失败请使用「最稳妥转换」。", + diagnostic: "ffmpeg:container-incompatible" + ) + } + if message == "MP4Flow:launch-failed" { + return ConversionFailure( + reasonKey: "无法启动转换工具", + solutionKey: "请在「环境体检」检查 FFmpeg,修复安装或权限后重试。", + diagnostic: "ffmpeg:launch-failed" + ) + } if message == "MP4Flow:disk-full" { - return ConversionFailure(reason: L10n.text("输出磁盘空间不足"), solution: L10n.text("请释放空间或选择其他输出位置后重试。"), diagnostic: "ffmpeg:disk-full") + return ConversionFailure(reasonKey: "输出磁盘空间不足", solutionKey: "请释放空间或选择其他输出位置后重试。", 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") + if message == "MP4Flow:missing-source" { + return ConversionFailure(reasonKey: "源文件已移动、删除或无法访问", solutionKey: "请在访达中确认源文件仍存在,重新添加后再试。", diagnostic: "ffmpeg:missing-source") + } + if message == "MP4Flow:permission" { + return ConversionFailure(reasonKey: "没有读取或写入文件的权限", solutionKey: "请检查文件和输出文件夹权限后重试。", diagnostic: "ffmpeg:permission") } let text = message.lowercased() if text.contains("无进度") || text.contains("未响应") || text.contains("timed out") || text.contains("timeout") { return ConversionFailure( - reason: L10n.text("转换长时间无进度,已安全停止"), - solution: L10n.text("请确认视频可播放、磁盘可用后重试;仍失败请使用「最稳妥转换」。"), + reasonKey: "转换长时间无进度,已安全停止", + solutionKey: "请确认视频可播放、磁盘可用后重试;仍失败请使用「最稳妥转换」。", diagnostic: "ffmpeg:stalled" ) } if text.contains("no space left") || text.contains("disk full") { return ConversionFailure( - reason: L10n.text("输出磁盘空间不足"), - solution: L10n.text("请释放空间或选择其他输出位置后重试。"), + reasonKey: "输出磁盘空间不足", + solutionKey: "请释放空间或选择其他输出位置后重试。", diagnostic: "ffmpeg:disk-full" ) } if text.contains("permission denied") || text.contains("operation not permitted") { return ConversionFailure( - reason: L10n.text("没有读取或写入文件的权限"), - solution: L10n.text("请检查文件和输出文件夹权限后重试。"), + reasonKey: "没有读取或写入文件的权限", + solutionKey: "请检查文件和输出文件夹权限后重试。", diagnostic: "ffmpeg:permission" ) } if text.contains("unknown encoder") || text.contains("videotoolbox") { return ConversionFailure( - reason: L10n.text("硬件编码不可用"), - solution: L10n.text("请重试;仍失败请使用「最稳妥转换」。"), + reasonKey: "硬件编码不可用", + solutionKey: "请重试;仍失败请使用「最稳妥转换」。", diagnostic: "ffmpeg:hardware-encoder" ) } if text.contains("invalid data") || text.contains("moov atom") || text.contains("could not find codec") { return ConversionFailure( - reason: L10n.text("无法读取此视频的媒体数据"), - solution: L10n.text("请确认文件可播放,或使用「智能转换」生成新文件后重试。"), + reasonKey: "无法读取此视频的媒体数据", + solutionKey: "请确认文件可播放,或使用「智能转换」生成新文件后重试。", diagnostic: "ffmpeg:unreadable-media" ) } return ConversionFailure( - reason: L10n.text("转换未完成"), - solution: L10n.text("请重试;仍失败请使用「最稳妥转换」。"), + reasonKey: "转换未完成", + solutionKey: "请重试;仍失败请使用「最稳妥转换」。", diagnostic: "ffmpeg:failed" ) } @@ -1381,11 +1587,18 @@ final class ConverterStore: ObservableObject { let size = values.fileSize, size > 0 else { return ConversionFailure( - reason: L10n.text("输出文件为空或未写完整"), - solution: L10n.text("请检查磁盘空间后重试。"), + reasonKey: "输出文件为空或未写完整", + solutionKey: "请检查磁盘空间后重试。", diagnostic: "verify:empty-output" ) } + guard locateFFprobe(for: ffmpegURL) != nil else { + return ConversionFailure( + reasonKey: "FFprobe 不可用,无法验证输出", + solutionKey: "请在「环境体检」修复 FFprobe 后重新转换。", + diagnostic: "verify:ffprobe-unavailable" + ) + } var info = await probe(output, ffmpegURL: ffmpegURL) if !info.probeSucceeded && !cancellationRequested { try? await Task.sleep(nanoseconds: 300_000_000) @@ -1393,15 +1606,15 @@ final class ConverterStore: ObservableObject { } guard info.probeSucceeded else { return ConversionFailure( - reason: L10n.text("无法验证输出文件"), - solution: L10n.text("请安装 FFmpeg 和 ffprobe 后重试。"), - diagnostic: "verify:ffprobe-unavailable-or-failed" + reasonKey: "输出文件未通过媒体检查", + solutionKey: "请重新转换;仍失败请在「环境体检」检查 FFprobe,并确认磁盘可用。", + diagnostic: "verify:unreadable-output" ) } guard info.hasVideo else { return ConversionFailure( - reason: L10n.text("输出文件缺少视频流"), - solution: L10n.text("请使用「最稳妥转换」重试。"), + reasonKey: "输出文件缺少视频流", + solutionKey: "请使用「最稳妥转换」重试。", diagnostic: "verify:no-video-stream" ) } @@ -1434,78 +1647,81 @@ final class ConverterStore: ObservableObject { } catch is CancellationError { return .cancelled } catch { +#if compiler(>=6.4) + if let exportError = error as? SmartEnhanceExporter.ExportError { + let failure = Self.enhancementFailure(for: exportError) + if let index = items.firstIndex(where: { $0.id == id }) { items[index].failure = failure } + return .failure(failure.reason) + } +#endif return .failure(L10n.format("智能增强放大失败:%@", error.localizedDescription)) } } - private func runFFmpeg(ffmpegURL: URL, source: URL, output: URL, info: VideoInfo, preset: ConversionPreset, quality: ConversionQuality, resolution: OutputResolution, trim: ClipRange?, rotation: VideoRotation?, crop: CropRect?, id: UUID, count: Int, preferHardwarePipeline: Bool = true, preferHardwareEncoder: Bool = true) async -> ProcessResult { - let process = Process(), progress = Pipe(), errors = Pipe() - let store = self - let activity = ProcessActivity() +#if compiler(>=6.4) + /// Classify framework failures before localization; associated framework + /// messages can contain source paths and must not become public reports. + @available(macOS 27.0, *) + nonisolated static func enhancementFailure(for error: SmartEnhanceExporter.ExportError) -> ConversionFailure { + switch error { + case .unavailable: + return ConversionFailure(reasonKey: "此 Mac 不支持智能增强放大。", solutionKey: "请改用「智能转换」;智能增强需要受支持的 Apple 芯片与 macOS 27+。", diagnostic: "enhance:unavailable") + case .invalidSource, .reader: + return ConversionFailure(reasonKey: "无法读取用于智能增强的视频。", solutionKey: "请使用「智能转换」先转换为新文件,然后添加新文件重试。", diagnostic: "enhance:unreadable-source") + case .writer: + return ConversionFailure(reasonKey: "无法写入智能增强输出文件。", solutionKey: "请检查剩余空间和输出文件夹权限,或改用本地文件夹后重试。", diagnostic: "enhance:write-failed") + case .pixelBuffer: + return ConversionFailure(reasonKey: "无法准备智能增强的视频帧。", solutionKey: "请降低增强目标分辨率,关闭其他视频任务后重试。", diagnostic: "enhance:frame-resources") + case .pixelTransfer: + return ConversionFailure(reasonKey: "无法调整智能增强输出尺寸。", solutionKey: "请降低增强目标分辨率后重试。", diagnostic: "enhance:resize-failed") + case .processingTimeout: + return ConversionFailure(reasonKey: "智能增强处理超时", solutionKey: "请降低增强目标分辨率后重试。", diagnostic: "enhance:processing-timeout") + case .finalizationTimeout: + return ConversionFailure(reasonKey: "输出收尾超时", solutionKey: "请检查磁盘空间与连接,改用本地输出文件夹后重试。", diagnostic: "enhance:finalization-timeout") + } + } +#endif + + private func runFFmpeg(ffmpegURL: URL, source: URL, output: URL, info: VideoInfo, preset: ConversionPreset, quality: ConversionQuality, resolution: OutputResolution, trim: ClipRange?, rotation: VideoRotation?, crop: CropRect?, id: UUID, count: Int, copyVideoAndEncodeAudio: Bool = false, preferHardwarePipeline: Bool = true, preferHardwareEncoder: Bool = true) async -> ProcessResult { + guard !cancellationRequested else { return .cancelled } let generation = UUID() + let usedHardwarePipeline = preferHardwarePipeline && canUseVideoToolboxPipeline(info: info, preset: preset, crop: crop) + let usedHardwareEncoder = preferHardwareEncoder && usesVideoToolboxEncoder(preset) 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 - let stderr = ErrorBuffer() - 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, generation: generation) } - } - errors.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData; guard !data.isEmpty else { return } - 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) } - else { continuation.resume(returning: .failure(store.friendlyError(stderr.read(), code: finished.terminationStatus))) } - } - } - activeProcesses[id] = process - 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 - } - let timeout = ffmpegNoProgressTimeout - let grace = ffmpegTerminationGrace - Task { [weak process, weak self] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 5_000_000_000) - guard !Task.isCancelled, let process, process.isRunning else { return } - guard activity.inactivity() >= timeout else { continue } - let message = L10n.text("FFmpeg 长时间无进度,已安全停止。") - guard activity.markTimedOut(message) else { return } - guard let self, !self.cancellationRequested else { return } - self.statusText = L10n.text("转换长时间无进度,正在安全停止") - process.terminate() - try? await Task.sleep(nanoseconds: grace) - if process.isRunning { - _ = Darwin.kill(process.processIdentifier, SIGKILL) - } - return - } + let runner = FFmpegProcessRunner( + executable: ffmpegURL, + arguments: ffmpegArguments(source: source, output: output, info: info, preset: preset, quality: quality, resolution: resolution, trim: trim, rotation: rotation, crop: crop, copyVideoAndEncodeAudio: copyVideoAndEncodeAudio, preferHardwarePipeline: preferHardwarePipeline, preferHardwareEncoder: preferHardwareEncoder), + configuration: processConfiguration + ) + activeProcesses[id] = runner + defer { + activeProcesses.removeValue(forKey: id) + if progressGeneration[id] == generation { progressGeneration[id] = nil } + } + let result = await runner.run { [weak self] data in + Task { @MainActor [weak self] in + self?.consumeProgress(String(decoding: data, as: UTF8.self), duration: trim.map { $0.end - $0.start } ?? info.duration, id: id, count: count, generation: generation) } } + if cancellationRequested || result.wasCancelled { return .cancelled } + if result.timedOut { return .failure("MP4Flow:stalled") } + if result.launchError != nil { return .failure("MP4Flow:launch-failed") } + if result.succeeded { return .success } + let message = Self.ffmpegFailureMessage(result.standardError, code: result.terminationStatus ?? -1) + if let hint = FFmpegHardwareRetryClassifier.hint( + stderr: result.standardError, + usedHardwarePipeline: usedHardwarePipeline, + usedHardwareEncoder: usedHardwareEncoder + ) { + return .retryableFailure(message, hint) + } + return .failure(message) } - private func ffmpegArguments(source: URL, output: URL, info: VideoInfo, preset: ConversionPreset, quality: ConversionQuality, resolution: OutputResolution, trim: ClipRange?, rotation: VideoRotation?, crop: CropRect?, preferHardwarePipeline: Bool, preferHardwareEncoder: Bool) -> [String] { + private func ffmpegArguments(source: URL, output: URL, info: VideoInfo, preset: ConversionPreset, quality: ConversionQuality, resolution: OutputResolution, trim: ClipRange?, rotation: VideoRotation?, crop: CropRect?, copyVideoAndEncodeAudio: Bool, preferHardwarePipeline: Bool, preferHardwareEncoder: Bool) -> [String] { // Privacy-first default: never copy source metadata or chapters, which // may contain title, author, device, location, or editing information. var args = ["-hide_banner", "-nostdin", "-y", "-thread_queue_size", "1024"] @@ -1516,28 +1732,30 @@ final class ConverterStore: ObservableObject { if hardwarePipeline { args += ["-init_hw_device", "videotoolbox=vt", "-filter_hw_device", "vt", "-hwaccel", "videotoolbox", "-hwaccel_output_format", "videotoolbox_vld"] } - // For lossless trimming, seek before opening the input and copy only the - // requested duration. This avoids decoding the preceding portion of a - // long video and makes clipping effectively instantaneous. Stream-copy - // cuts may begin at the nearest keyframe, as indicated by the editor. - if let trim, preset == .remux { + // Input seeking decodes only from the preceding keyframe, including + // accurate discarding during re-encoding. Output seeking would decode + // the entire prefix of a long video before producing the first frame. + // Stream-copy cuts still begin near a keyframe, as the editor explains. + if let trim { args += ["-ss", ffmpegTime(trim.start), "-i", source.path] } else { args += ["-i", source.path] } args += ["-map", "0:v:0?", "-map", "0:a:0?", "-map_metadata", "-1", "-map_chapters", "-1"] if let trim { - if preset == .remux { - args += ["-t", ffmpegTime(trim.end - trim.start)] - } else { - args += ["-ss", ffmpegTime(trim.start), "-to", ffmpegTime(trim.end)] - } + args += ["-t", ffmpegTime(trim.end - trim.start)] + } + if copyVideoAndEncodeAudio { + args += ["-c:v", "copy"] + audioArguments(inputAudioCodec: info.audioCodec, audioRate: info.safeAudioRate, forceAAC: true) + if info.videoCodec?.lowercased() == "hevc" { args += ["-tag:v", "hvc1"] } + return args + ["-movflags", "+faststart", "-progress", "pipe:1", "-nostats", output.path] } // Crop coordinates are chosen in the source orientation; rotate before // resizing so a selected output height always applies to the final // orientation. `transpose_vt` leaves a non-square input SAR unchanged, // unlike the CPU transpose filter, so invert it after a 90° turn. - let rotatedSourceHeight = rotation?.swapsDimensions == true ? info.width : info.height + let editedGeometry = info.geometry(crop: crop, rotation: rotation) + let rotatedSourceHeight = editedGeometry?.height var filters = [crop?.filter].compactMap { $0 } if let rotation { filters.append(hardwarePipeline ? rotation.videoToolboxFilter : rotation.filter) @@ -1545,10 +1763,13 @@ final class ConverterStore: ObservableObject { filters.append("setsar=1/sar") } } - if let resolutionFilter = resolution.filter(sourceHeight: rotatedSourceHeight, quality: quality, useVideoToolbox: hardwarePipeline) { + if let resolutionFilter = resolution.filter(sourceHeight: rotatedSourceHeight, quality: quality, useVideoToolbox: hardwarePipeline, canSkipUnchangedSize: editedGeometry?.canSkipUnchangedSize == true) { filters.append(resolutionFilter) } if !filters.isEmpty { args += ["-vf", filters.joined(separator: ",")] } + if preset != .remux, let timeBase = info.encodingTimeBase { + args += ["-enc_time_base:v", timeBase] + } let videoRate = info.outputRate(preset: preset, quality: quality, resolution: resolution, sourceHeight: rotatedSourceHeight), audioRate = info.safeAudioRate switch preset { case .qualityTop: @@ -1600,16 +1821,17 @@ final class ConverterStore: ObservableObject { !forceAAC && inputAudioCodec?.lowercased() == "aac" ? ["-c:a", "copy"] : ["-c:a", "aac", "-b:a", rate(audioRate)] } private func rate(_ bits: Int) -> String { "\(max(64, bits / 1_000))k" } - private func outputSummary(_ info: VideoInfo, preset: ConversionPreset, quality: ConversionQuality, resolution: OutputResolution) -> String { + private func outputSummary(_ info: VideoInfo, preset: ConversionPreset, quality: ConversionQuality, resolution: OutputResolution, sourceHeight: Int? = nil) -> String { let sizeLabel = resolution.targetHeight.map { " · \($0)P" } ?? "" + let videoRate = Double(info.outputRate(preset: preset, quality: quality, resolution: resolution, sourceHeight: sourceHeight)) / 1_000_000 if preset == .qualityTop { - return L10n.format("保留源码率 %.1f Mbps%@", Double(info.outputRate(preset: preset, quality: quality, resolution: resolution)) / 1_000_000, sizeLabel) + return L10n.format("保留源码率 %.1f Mbps%@", videoRate, sizeLabel) } - if preset == .h264Cap8 { return "H.264 \(String(format: "%.1f", Double(info.outputRate(preset: preset, quality: quality, resolution: resolution)) / 1_000_000)) Mbps · AAC 192k\(sizeLabel)" } + if preset == .h264Cap8 { return "H.264 \(String(format: "%.1f", videoRate)) Mbps · AAC 192k\(sizeLabel)" } if preset == .h264Software { return "H.264 · CRF \(quality.crf)\(sizeLabel)" } if preset == .smartEnhanceUpscale { return L10n.text("智能增强放大(实验性)") + sizeLabel } if preset == .remux { return L10n.text("不重新编码") } - return L10n.format("目标码率 %.1f Mbps", Double(info.outputRate(preset: preset, quality: quality, resolution: resolution)) / 1_000_000) + sizeLabel + return L10n.format("目标码率 %.1f Mbps", videoRate) + sizeLabel } private func consumeProgress(_ text: String, duration: Double?, id: UUID, count: Int, generation: UUID) { @@ -1647,65 +1869,45 @@ final class ConverterStore: ObservableObject { private func refreshProgress(_ count: Int) { guard count > 0 else { overallProgress = nil; return } let jobs = items.filter { queueIDs.contains($0.id) } + guard !jobs.isEmpty else { overallProgress = nil; return } if jobs.contains(where: { if case .running(nil) = $0.state { return true }; return false }) { overallProgress = nil; return } - overallProgress = jobs.reduce(0.0) { sum, item in switch item.state { case .complete, .failed, .cancelled: sum + 1; case .running(let progress), .merging(let progress): sum + (progress ?? 0); case .waiting: sum } } / Double(count) + // Completed or waiting rows can be removed during a batch. Base the + // denominator on the remaining snapshot rather than the original size. + overallProgress = jobs.reduce(0.0) { sum, item in switch item.state { case .complete, .failed, .cancelled: sum + 1; case .running(let progress), .merging(let progress): sum + (progress ?? 0); case .waiting: sum } } / Double(jobs.count) } private func probe(_ source: URL, ffmpegURL: URL) async -> VideoInfo { guard let probe = locateFFprobe(for: ffmpegURL) else { return Self.unavailableProbeInfo() } - return await Self.inspectMedia(at: source, using: probe) + guard !cancellationRequested else { return Self.unavailableProbeInfo() } + let id = UUID() + let runner = FFmpegProcessRunner( + executable: probe, + arguments: Self.probeArguments(for: source), + configuration: FFmpegProcessRunner.Configuration(inactivityTimeout: 30) + ) + activeProcesses[id] = runner + defer { activeProcesses.removeValue(forKey: id) } + return await Self.inspectMedia(at: source, using: probe, runner: runner) } /// 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 - 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) } - } - } - 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 } - process.terminate() - try? await Task.sleep(nanoseconds: 3_000_000_000) - if process.isRunning { - _ = Darwin.kill(process.processIdentifier, SIGKILL) - } - } - } + nonisolated static func inspectMedia(at source: URL, using probe: URL, runner suppliedRunner: FFmpegProcessRunner? = nil) async -> VideoInfo { + let runner = suppliedRunner ?? FFmpegProcessRunner( + executable: probe, + arguments: probeArguments(for: source), + configuration: FFmpegProcessRunner.Configuration(inactivityTimeout: 30) + ) + let result = await runner.run() + return result.succeeded + ? (decodeProbe(result.standardOutput) ?? unavailableProbeInfo()) + : unavailableProbeInfo() + } + + 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] } nonisolated private static func unavailableProbeInfo() -> VideoInfo { @@ -1714,7 +1916,8 @@ 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 video = response.streams.first { $0.codecType == "video" } + let videoStreams = response.streams.filter { $0.codecType == "video" } + let video = videoStreams.first let audioStreams = response.streams.filter { $0.codecType == "audio" } let subtitleStreams = response.streams.filter { $0.codecType == "subtitle" } let audio = audioStreams.first @@ -1722,11 +1925,25 @@ final class ConverterStore: ObservableObject { guard let total = Int($0.bitRate ?? "") else { return nil } return max(total - (Int(audio?.bitRate ?? "") ?? 0), 1) } - return VideoInfo(duration: Double(response.format?.duration ?? ""), videoRate: videoRate, audioRate: Int(audio?.bitRate ?? ""), width: video?.width, height: video?.height, hasVideo: video != nil, videoCodec: video?.codecName, audioCodec: audio?.codecName, frameRate: video?.rFrameRate, pixelFormat: video?.pixelFormat, audioSampleRate: audio?.sampleRate, audioChannels: audio?.channels, audioTrackCount: audioStreams.count, subtitleTrackCount: subtitleStreams.count, colorTransfer: video?.colorTransfer, probeSucceeded: true) + var info = VideoInfo(duration: Double(response.format?.duration ?? ""), videoRate: videoRate, audioRate: Int(audio?.bitRate ?? ""), width: video?.width, height: video?.height, hasVideo: video != nil, videoCodec: video?.codecName, audioCodec: audio?.codecName, frameRate: video?.rFrameRate, pixelFormat: video?.pixelFormat, audioSampleRate: audio?.sampleRate, audioChannels: audio?.channels, audioTrackCount: audioStreams.count, subtitleTrackCount: subtitleStreams.count, colorTransfer: video?.colorTransfer, probeSucceeded: true) + let rotationTag = video?.tags?["rotate"] + let rotation = video?.sideDataList?.compactMap(\.rotation).first ?? rotationTag.flatMap(Double.init) + if rotationTag == nil || rotation != nil { + info.displayGeometry = VideoGeometry.displayed(width: video?.width, height: video?.height, sampleAspectRatio: video?.sampleAspectRatio, rotation: rotation) + } + info.timeBase = video?.timeBase + info.sampleAspectRatio = video?.sampleAspectRatio + info.displayRotation = rotation + info.videoTrackCount = videoStreams.count + info.chapterCount = response.chapters?.count ?? 0 + return info } private func locateFFmpeg() -> URL? { - ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/local/bin/ffmpeg", "/usr/bin/ffmpeg"] + if let ffmpegOverride { + return FileManager.default.isExecutableFile(atPath: ffmpegOverride.path) ? ffmpegOverride : nil + } + return ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/local/bin/ffmpeg", "/usr/bin/ffmpeg"] .map(URL.init(fileURLWithPath:)) .first { FileManager.default.isExecutableFile(atPath: $0.path) } } @@ -1758,23 +1975,32 @@ final class ConverterStore: ObservableObject { } private func ffmpegTime(_ seconds: Double) -> String { String(format: "%.3f", seconds) } 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 { + nonisolated static func ffmpegFailureMessage(_ 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("could not write header") || text.contains("muxer") { return L10n.text("该流无法直接封装为 MP4,请改用 H.264 或 HEVC 转码。") } - return L10n.format("FFmpeg 转换失败(退出码 %d)。", code) + if text.contains("invalid data found when processing input") || text.contains("moov atom not found") || text.contains("could not find codec parameters") { + return "MP4Flow:unreadable-media" + } + if FFmpegHardwareRetryClassifier.hint(stderr: stderr, usedHardwarePipeline: false, usedHardwareEncoder: true) == .softwareEncoder { + return "MP4Flow:hardware-encoder" + } + if text.contains("unknown encoder") { return "MP4Flow:software-encoder" } + let containerFailures = [ + "could not find tag for codec", "codec not currently supported in container", + "codec not supported in container", "muxer does not support" + ] + if containerFailures.contains(where: text.contains) { return "MP4Flow:container-incompatible" } + return "MP4Flow:conversion-failed" } private func friendlyMergeError(_ stderr: String, code: Int32) -> String { + let normalized = Self.ffmpegFailureMessage(stderr, code: code) + guard normalized == "MP4Flow:conversion-failed" else { return normalized } let text = stderr.lowercased() if text.contains("non monotonous") || text.contains("invalid data") || text.contains("concat") { return L10n.text("片段时间轴或流参数不兼容。请先使用相同预设转换为 MP4 后再合并。") } - if text.contains("permission denied") || text.contains("no such file") { - return L10n.text("无法读取片段或写入输出位置,请检查文件与权限。") - } - return L10n.format("FFmpeg 合并失败(退出码 %d)。", code) + return normalized } } diff --git a/Sources/FastConvert/DependencyHealth.swift b/Sources/FastConvert/DependencyHealth.swift index 94ad7ee..97c76c0 100644 --- a/Sources/FastConvert/DependencyHealth.swift +++ b/Sources/FastConvert/DependencyHealth.swift @@ -5,6 +5,20 @@ import Foundation import SwiftUI import VideoToolbox +/// Match the converter's executable selection exactly: ffprobe must come from +/// the selected FFmpeg installation, not an unrelated later search directory. +/// Existing, unusable candidates are retained only for failure diagnostics. +enum DependencyToolResolver { + static func resolve(in directories: [URL]) -> (ffmpeg: URL?, ffprobe: URL?) { + let candidates = directories.map { $0.appendingPathComponent("ffmpeg") } + let ffmpeg = candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) } + ?? candidates.first { FileManager.default.fileExists(atPath: $0.path) } + let sibling = ffmpeg?.deletingLastPathComponent().appendingPathComponent("ffprobe") + let ffprobe = sibling.flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : nil } + return (ffmpeg, ffprobe) + } +} + /// A lightweight preflight for the local tools and folders MP4Flow needs. /// /// This intentionally runs outside `ConverterStore`: checking an executable or @@ -147,8 +161,9 @@ private enum DependencyHealthProbe { ffprobe = previous.ffprobe hardware = previous.videoToolbox } else { - ffmpeg = await inspectExecutable(named: "ffmpeg") - ffprobe = await inspectExecutable(named: "ffprobe") + let tools = DependencyToolResolver.resolve(in: executableDirectories.map { URL(fileURLWithPath: $0) }) + ffmpeg = await inspectExecutable(named: "ffmpeg", at: tools.ffmpeg) + ffprobe = await inspectExecutable(named: "ffprobe", at: tools.ffprobe) let encoders: String? if let path = ffmpeg.path { let result = await run(URL(fileURLWithPath: path), arguments: ["-hide_banner", "-encoders"], timeout: 4) @@ -179,13 +194,15 @@ private enum DependencyHealthProbe { }.value } - 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 { + private static func inspectExecutable(named name: String, at executable: URL?) async -> ExecutableHealth { + guard let executable else { return ExecutableHealth(level: .failed, name: name == "ffprobe" ? "FFprobe" : "FFmpeg", path: nil, version: nil) } + guard FileManager.default.isExecutableFile(atPath: executable.path) else { + return ExecutableHealth(level: .failed, name: name == "ffprobe" ? "FFprobe" : "FFmpeg", path: executable.path, version: nil) + } + 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) @@ -362,7 +379,7 @@ struct DependencyHealthStatusBar: View { var body: some View { HStack(spacing: 8) { - TablerIcon(name: statusIcon(for: monitor.report.overallLevel), color: color(for: monitor.report.overallLevel)) + TablerIcon(name: statusIcon(for: displayedLevel), color: color(for: displayedLevel)) .frame(width: 15, height: 15) .rotationEffect(monitor.isChecking ? .degrees(-75) : .zero) Text(compactSummary) @@ -396,7 +413,7 @@ struct DependencyHealthStatusBar: View { .background(backgroundColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(color(for: monitor.report.overallLevel).opacity(0.24), lineWidth: 1) + .stroke(color(for: displayedLevel).opacity(0.24), lineWidth: 1) } .sheet(isPresented: $isShowingDetails) { DependencyHealthSheet( @@ -411,19 +428,23 @@ struct DependencyHealthStatusBar: View { private var compactSummary: String { let report = monitor.report if monitor.isChecking { return L10n.text("正在检查转换环境…") } - if report.ffmpeg.level == .failed { return L10n.text("需要安装 FFmpeg") } - if report.ffprobe.level == .failed { return L10n.text("FFprobe 不可用") } + if report.ffmpeg.level == .failed { return L10n.text(report.ffmpeg.path == nil ? "需要安装 FFmpeg" : "FFmpeg 无法运行") } + if report.ffprobe.level == .failed { return L10n.text(report.ffprobe.path == nil ? "FFprobe 不可用" : "FFprobe 无法运行") } if report.output.level == .failed { return L10n.text("输出位置不可写") } if report.output.level == .warning && report.output.checkedDirectoryCount == 0 { return L10n.text("添加视频后检查输出位置。") } if report.output.level == .warning { return L10n.text("可用空间较少") } - if report.videoToolbox.level == .warning { return L10n.text("将使用软件编码") } + if report.videoToolbox.level == .warning { return L10n.text("硬件加速不可用,仍可转换") } return L10n.format("FFmpeg %@ 已就绪", report.ffmpeg.version ?? "—") } + private var displayedLevel: DependencyHealthLevel { + monitor.isChecking ? .checking : monitor.report.overallLevel + } + private var backgroundColor: Color { - switch monitor.report.overallLevel { + switch displayedLevel { case .failed: BrandColor.warningBackground case .warning: BrandColor.warningBackground.opacity(0.62) case .ready, .checking: BrandColor.surface @@ -459,7 +480,7 @@ private struct DependencyHealthSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .top, spacing: 10) { - Mark(kind: .healthCheck, color: color(for: monitor.report.overallLevel)) + Mark(kind: .healthCheck, color: color(for: monitor.isChecking ? .checking : monitor.report.overallLevel)) .frame(width: 22, height: 22) VStack(alignment: .leading, spacing: 2) { Text(L10n.text("环境体检")) @@ -494,7 +515,10 @@ private struct DependencyHealthSheet: View { title: "FFmpeg", level: monitor.report.ffmpeg.level, detail: executableDetail(monitor.report.ffmpeg), - fix: monitor.report.ffmpeg.level == .failed ? L10n.text("请在终端执行 brew install ffmpeg,然后重新检查。") : nil + fix: monitor.report.ffmpeg.level == .failed + ? L10n.text(monitor.report.ffmpeg.path == nil + ? "请在终端执行 brew install ffmpeg,然后重新检查。" + : "请重新安装 FFmpeg:brew reinstall ffmpeg。") : nil ) healthRow( title: "FFprobe", @@ -506,7 +530,7 @@ private struct DependencyHealthSheet: View { title: "VideoToolbox", level: monitor.report.videoToolbox.level, detail: hardwareDetail, - fix: monitor.report.videoToolbox.level == .warning ? L10n.text("将使用软件编码;速度可能较慢。") : nil + fix: monitor.report.videoToolbox.level == .warning ? L10n.text("需要重新编码时将使用软件处理,速度可能较慢。") : nil ) if monitor.report.output.checkedDirectoryCount > 0 || monitor.report.output.level == .failed { healthRow( @@ -531,7 +555,14 @@ private struct DependencyHealthSheet: View { HStack(spacing: 8) { if monitor.report.needsFFmpegInstall { - Button(L10n.text("复制安装命令")) { copyFFmpegInstallCommand() } + Button(L10n.text("复制安装命令")) { + if monitor.report.ffmpeg.path == nil { + copyFFmpegInstallCommand() + } else { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString("brew reinstall ffmpeg", forType: .string) + } + } .buttonStyle(HealthSheetActionButtonStyle(primary: true)) Button(L10n.text("打开 Homebrew 官网")) { openHomebrewWebsite() } .buttonStyle(HealthSheetActionButtonStyle(primary: false)) diff --git a/Sources/FastConvert/FFmpegProcessRunner.swift b/Sources/FastConvert/FFmpegProcessRunner.swift new file mode 100644 index 0000000..8077bde --- /dev/null +++ b/Sources/FastConvert/FFmpegProcessRunner.swift @@ -0,0 +1,282 @@ +import Darwin +import Foundation + +/// Owns one child process from launch through pipe EOF. The caller can cancel +/// inspection, conversion, or merging through the same bounded shutdown path. +/// MP4Flow launches FFmpeg/ffprobe directly; this does not terminate arbitrary +/// descendant process trees or unrelated processes in a shell's process group. +final class FFmpegProcessRunner: @unchecked Sendable { + enum ActivityPolicy: Sendable { + case anyOutput + case advancingFFmpegProgress + } + + struct Configuration: Sendable { + var inactivityTimeout: TimeInterval = 90 + var terminationGrace: TimeInterval = 3 + var pollingInterval: TimeInterval = 0.25 + var activityPolicy: ActivityPolicy = .anyOutput + var maximumOutputBytes = 8_000_000 + var maximumErrorBytes = 4_000 + } + + struct Result: Sendable { + let terminationStatus: Int32? + let standardOutput: Data + let standardError: String + let wasCancelled: Bool + let timedOut: Bool + let launchError: String? + + var succeeded: Bool { + terminationStatus == 0 && !wasCancelled && !timedOut && launchError == nil + } + } + + private let executable: URL + private let arguments: [String] + private let configuration: Configuration + private let lock = NSLock() + private var process: Process? + private var cancelled = false + private var timedOut = false + private var finished = false + private var lastActivity = ProcessInfo.processInfo.systemUptime + private var output = Data() + private var errors = Data() + private var activityBuffer = "" + private var progressValues: [String: Int64] = [:] + private var watchdog: Task? + private var forcedTermination: Task? + + init(executable: URL, arguments: [String], configuration: Configuration = Configuration()) { + self.executable = executable + self.arguments = arguments + self.configuration = configuration + } + + func cancel() { + requestStop(isTimeout: false) + } + + func run(onStandardOutput: @escaping @Sendable (Data) -> Void = { _ in }) async -> Result { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let child = Process() + let stdout = Pipe() + let stderr = Pipe() + let drains = DispatchGroup() + child.executableURL = executable + child.arguments = arguments + child.standardInput = FileHandle.nullDevice + child.standardOutput = stdout + child.standardError = stderr + + // Read both streams independently, including their final bytes. + // Waiting for exit before draining can fill a pipe; removing a + // readability handler at exit can discard a final error packet. + drains.enter() + DispatchQueue.global(qos: .utility).async { [self] in + defer { drains.leave() } + Self.drain(stdout.fileHandleForReading) { chunk in + appendOutput(chunk) + onStandardOutput(chunk) + } + } + drains.enter() + DispatchQueue.global(qos: .utility).async { [self] in + defer { drains.leave() } + Self.drain(stderr.fileHandleForReading) { chunk in + appendError(chunk) + } + } + child.terminationHandler = { [self] child in + // Stop the timer immediately, even while EOF is draining. + stopWatchdog() + drains.notify(queue: .global(qos: .utility)) { + self.complete(status: child.terminationStatus, launchError: nil, continuation: continuation) + } + } + + var launchError: String? + lock.lock() + if !cancelled { + process = child + lastActivity = ProcessInfo.processInfo.systemUptime + do { try child.run() } + catch { launchError = error.localizedDescription } + } + let didLaunch = process != nil && launchError == nil + lock.unlock() + // Only the child's duplicated descriptors should keep the + // pipes open. Closing the parent ends also makes launch-failure + // and pre-launch cancellation drains reach EOF reliably. + try? stdout.fileHandleForWriting.close() + try? stderr.fileHandleForWriting.close() + + if didLaunch { + startWatchdog() + } else { + drains.notify(queue: .global(qos: .utility)) { [self] in + complete(status: nil, launchError: launchError, continuation: continuation) + } + } + } + } onCancel: { + self.cancel() + } + } + + private static func drain(_ handle: FileHandle, consume: (Data) -> Void) { + defer { try? handle.close() } + let descriptor = handle.fileDescriptor + var buffer = [UInt8](repeating: 0, count: 64 * 1_024) + while true { + // Foundation's read(upToCount:) may fill the entire requested + // buffer before returning on macOS. FFmpeg's small progress packets + // then arrive only at EOF, freezing the UI and misleading the + // inactivity watchdog. A pipe read returns as soon as bytes exist; + // the buffer is a capacity, never a minimum packet size. + let count = buffer.withUnsafeMutableBytes { bytes in + Darwin.read(descriptor, bytes.baseAddress, bytes.count) + } + if count > 0 { + consume(Data(buffer.prefix(count))) + } else if count == 0 { + return + } else if errno != EINTR { + return + } + } + } + + private func appendOutput(_ data: Data) { + lock.lock() + switch configuration.activityPolicy { + case .anyOutput: + lastActivity = ProcessInfo.processInfo.systemUptime + case .advancingFFmpegProgress: + let combined = activityBuffer + String(decoding: data, as: UTF8.self) + let lines = combined.split(separator: "\n", omittingEmptySubsequences: false) + activityBuffer = combined.hasSuffix("\n") ? "" : String((lines.last ?? "").suffix(1_024)) + for line in (combined.hasSuffix("\n") ? lines : lines.dropLast()) { + let fields = line.split(separator: "=", maxSplits: 1) + guard fields.count == 2, + ["out_time_us", "out_time_ms", "frame", "total_size"].contains(String(fields[0])), + let value = Int64(fields[1]), value > (progressValues[String(fields[0])] ?? 0) else { continue } + progressValues[String(fields[0])] = value + lastActivity = ProcessInfo.processInfo.systemUptime + } + } + if output.count < configuration.maximumOutputBytes { + output.append(data.prefix(configuration.maximumOutputBytes - output.count)) + } + lock.unlock() + } + + private func appendError(_ data: Data) { + lock.lock() + errors.append(data) + if errors.count > configuration.maximumErrorBytes { + errors.removeFirst(errors.count - configuration.maximumErrorBytes) + } + lock.unlock() + } + + private func startWatchdog() { + let interval = UInt64(max(configuration.pollingInterval, 0.01) * 1_000_000_000) + let task = Task.detached(priority: .utility) { [weak self] in + while !Task.isCancelled { + do { try await Task.sleep(nanoseconds: interval) } + catch { return } + guard let self else { return } + if self.hasExpired(), self.requestStop(isTimeout: true) { + return + } + if self.hasExited() { return } + } + } + lock.lock() + if finished || process?.isRunning != true { task.cancel() } + else { watchdog = task } + lock.unlock() + } + + private func hasExpired() -> Bool { + lock.lock() + defer { lock.unlock() } + return !finished && process?.isRunning == true + && ProcessInfo.processInfo.systemUptime - lastActivity >= configuration.inactivityTimeout + } + + private func hasExited() -> Bool { + lock.lock() + defer { lock.unlock() } + return finished || process?.isRunning != true + } + + @discardableResult + private func requestStop(isTimeout: Bool) -> Bool { + lock.lock() + guard !finished else { lock.unlock(); return false } + if isTimeout { + // The child may have exited, or a progress packet may have arrived, + // between the watchdog's first observation and taking this lock. + // Do not turn an already successful exit into a timeout failure. + guard process?.isRunning == true, + ProcessInfo.processInfo.systemUptime - lastActivity >= configuration.inactivityTimeout else { + lock.unlock() + return false + } + timedOut = true + } else { + // User cancellation remains meaningful during the EOF-drain and + // verification gap: the caller must not publish its output then. + cancelled = true + } + guard forcedTermination == nil, let child = process, child.isRunning else { + lock.unlock() + return true + } + child.terminate() + let grace = UInt64(max(configuration.terminationGrace, 0) * 1_000_000_000) + forcedTermination = Task.detached(priority: .utility) { [weak self, child] in + do { try await Task.sleep(nanoseconds: grace) } + catch { return } + guard let self, !self.hasExited(), child.isRunning else { return } + _ = Darwin.kill(child.processIdentifier, SIGKILL) + } + lock.unlock() + return true + } + + private func stopWatchdog() { + lock.lock() + watchdog?.cancel() + watchdog = nil + forcedTermination?.cancel() + forcedTermination = nil + lock.unlock() + } + + private func complete(status: Int32?, launchError: String?, continuation: CheckedContinuation) { + lock.lock() + guard !finished else { lock.unlock(); return } + finished = true + watchdog?.cancel() + forcedTermination?.cancel() + watchdog = nil + forcedTermination = nil + process = nil + let result = Result( + terminationStatus: status, + standardOutput: output, + standardError: String(decoding: errors, as: UTF8.self), + wasCancelled: cancelled, + timedOut: timedOut, + launchError: launchError + ) + lock.unlock() + continuation.resume(returning: result) + } +} diff --git a/Sources/FastConvert/FastConvertApp.swift b/Sources/FastConvert/FastConvertApp.swift index 932b9a3..997b5fc 100644 --- a/Sources/FastConvert/FastConvertApp.swift +++ b/Sources/FastConvert/FastConvertApp.swift @@ -276,7 +276,7 @@ private struct UpdateSettingsView: View { .toggleStyle(.switch) Text(automaticallyCheckForUpdates - ? L10n.text("首次启动不联网;从第二次启动起,每天最多检查一次 GitHub 公开发布信息。") + ? L10n.text("首次启动不自动检查更新;之后通常每天检查一次,失败后稍后重试。") : L10n.text("自动检查已关闭。你仍可随时通过“帮助 > 检查更新”手动检查。")) .font(.system(size: 12)) .foregroundStyle(BrandColor.textSecondary) @@ -285,10 +285,9 @@ private struct UpdateSettingsView: View { Divider() HStack(spacing: 8) { - Circle() - .fill(updateChecker.isChecking ? BrandColor.warning : BrandColor.success) - .frame(width: 8, height: 8) - Text(updateChecker.isChecking ? L10n.text("正在检查更新…") : L10n.text("更新检查只读取 GitHub 公开发布信息,不上传视频或使用数据。")) + TablerIcon(name: updateChecker.isChecking ? .loader2 : .infoCircle, color: BrandColor.textSecondary) + .frame(width: 14, height: 14) + Text(updateChecker.isChecking ? L10n.text("正在检查更新…") : (updateChecker.lastCheckFailed ? L10n.text("上次未能检查更新,可手动重试。") : L10n.text("更新检查只读取 GitHub 公开发布信息,不上传视频或使用数据。"))) .font(.system(size: 12)) .foregroundStyle(BrandColor.textSecondary) } @@ -343,6 +342,7 @@ struct ContentView: View { @State private var isDropTargeted = false @StateObject private var dependencyHealth = DependencyHealthMonitor() @State private var showUpdateNotice = false + @State private var showProcessingPolicy = false @State private var updateNoticeMessage = "" @State private var updateDownloadURL: URL? @State private var clipItem: ConversionItem? @@ -391,12 +391,12 @@ struct ContentView: View { .allowsHitTesting(false) } } - .alert(L10n.text("需要安装 FFmpeg"), isPresented: $store.showFFmpegSetup) { + .alert(L10n.text("转换组件不可用"), isPresented: $store.showFFmpegSetup) { Button(L10n.text("复制安装命令")) { store.copyFFmpegInstallCommand() } Button(L10n.text("打开 Homebrew 官网")) { store.openHomebrewWebsite() } Button(L10n.text("稍后再说"), role: .cancel) {} } message: { - Text(L10n.text("MP4Flow 依赖本机的 FFmpeg 进行转码,但尚未检测到它。\n\n1. 打开「终端」\n2. 若尚未安装 Homebrew,请在官网完成安装\n3. 粘贴并执行:brew install ffmpeg\n4. 安装结束后,完全退出并重新打开 MP4Flow。")) + Text(L10n.text("FFmpeg 或 ffprobe 未安装,或无法运行。请先在「环境体检」查看缺少的组件与修复方法。\n\n如未安装,可在终端执行:brew install ffmpeg\n安装后重新检查。")) } .alert(L10n.text("检查更新"), isPresented: $showUpdateNotice) { if updateDownloadURL != nil { @@ -418,7 +418,7 @@ struct ContentView: View { Button(L10n.text("取消"), role: .cancel) { store.cancelCompatibleMerge() } Button(L10n.text("开始兼容合并")) { store.startCompatibleMerge() } } message: { - Text(L10n.text("检测到片段的分辨率、帧率或音频参数不同,无法安全无损拼接。继续后将以队列中最大分辨率为目标尺寸:较小视频等比例放大;比例不同则居中补边,不会拉伸画面。输出统一编码为 H.264 + AAC MP4。")) + Text(L10n.text("这些片段暂不适合直接拼接。建议先做统一规格的转码,再合并。\n\n也可选择「开始兼容合并」,自动统一规格后合并。以最大分辨率为目标,等比例缩放并居中补边,输出 H.264 + AAC MP4。")) } .sheet(item: $clipItem) { item in TrimEditor(source: item.source, initialRange: item.trimRange) { start, end in @@ -533,13 +533,13 @@ struct ContentView: View { menuWidth: usableWidth * 0.31 ) .frame(maxWidth: .infinity).frame(height: configurationControlHeight) - Text(store.preset.detail).font(AppFont.caption).foregroundStyle(BrandColor.textSecondary).lineLimit(2) + configurationHint(store.preset.detail) } configurationDivider configurationColumn(title: "画质大小", mark: .hardDrive, width: usableWidth * 0.25) { QualitySegmentedControl(selection: $store.quality, isEnabled: !store.isBusy && !store.preset.usesFixedQuality) .frame(height: configurationControlHeight) - Text(store.qualityDescription).font(AppFont.caption).foregroundStyle(BrandColor.textSecondary).lineLimit(2) + configurationHint(store.qualityDescription) } configurationDivider configurationColumn(title: "分辨率", mark: .resolution, width: usableWidth * 0.16) { @@ -556,8 +556,7 @@ struct ContentView: View { ) .frame(maxWidth: .infinity).frame(height: configurationControlHeight) } - Text(store.smartEnhanceTargets.isEmpty ? L10n.text("添加视频后显示可选分辨率。") : L10n.text("最高增强 2 倍。")) - .font(AppFont.caption).foregroundStyle(BrandColor.textSecondary).lineLimit(2) + configurationHint(store.smartEnhanceTargets.isEmpty ? L10n.text("添加视频后显示可选分辨率。") : L10n.text("最高增强 2 倍。")) } else { MP4FlowMenuPicker( selection: $store.outputResolution, @@ -566,7 +565,7 @@ struct ContentView: View { menuWidth: usableWidth * 0.16 ) .frame(maxWidth: .infinity).frame(height: configurationControlHeight) - Text(store.outputResolution.detail).font(AppFont.caption).foregroundStyle(BrandColor.textSecondary).lineLimit(2) + configurationHint(store.outputResolution.detail) } } configurationDivider @@ -579,22 +578,30 @@ struct ContentView: View { ) .frame(maxWidth: .infinity) .frame(height: configurationControlHeight) - Text(store.outputDirectory == nil ? "默认输出至源文件夹" : "已选择自定义输出文件夹") - .font(AppFont.caption).foregroundStyle(BrandColor.textSecondary).lineLimit(2) + configurationHint(L10n.text(store.outputDirectory == nil ? "默认输出至源文件夹" : "已选择自定义输出文件夹")) } } } .frame(maxWidth: .infinity) - .frame(height: 102) + .frame(height: 116) .padding(.horizontal, 18) .padding(.vertical, 16) - .frame(height: 134) + .frame(height: 148) .background(BrandColor.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(BrandColor.stroke, lineWidth: 1) } } private var configurationDivider: some View { - Rectangle().fill(BrandColor.stroke).frame(width: 1, height: 92).padding(.horizontal, 14) + Rectangle().fill(BrandColor.stroke).frame(width: 1, height: 106).padding(.horizontal, 14) + } + + private func configurationHint(_ text: String) -> some View { + Text(text) + .font(AppFont.caption) + .foregroundStyle(BrandColor.textSecondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .help(text) } private func configurationColumn(title: String, mark: MarkKind, width: CGFloat, @ViewBuilder content: () -> Content) -> some View { @@ -638,11 +645,32 @@ struct ContentView: View { HStack { Text(L10n.format("转换队列(%d)", store.items.count)).font(AppFont.section) Spacer() + Button { showProcessingPolicy.toggle() } label: { + HStack(spacing: 4) { + TablerIcon(name: .infoCircle, color: BrandColor.textSecondary).frame(width: 12, height: 12) + Text(L10n.text("处理策略")).font(AppFont.caption) + } + .foregroundStyle(BrandColor.textSecondary) + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.text("处理策略")) + .popover(isPresented: $showProcessingPolicy, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.text("处理策略")).font(AppFont.section) + Text(L10n.text("默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。")) + .font(AppFont.body) + .fixedSize(horizontal: false, vertical: true) + HStack { + Spacer() + Button(L10n.text("知道了")) { showProcessingPolicy = false } + .buttonStyle(.plain) + .foregroundStyle(BrandColor.blue) + } + } + .padding(16) + .frame(width: 340) + } } - Text(L10n.text("默认输出第一路视频和音频,并移除容器元数据和章节;开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。")) - .font(AppFont.caption) - .foregroundStyle(BrandColor.textSecondary) - .fixedSize(horizontal: false, vertical: true) } .padding(.horizontal, 14).padding(.vertical, 11) Divider().overlay(BrandColor.stroke) @@ -658,8 +686,11 @@ struct ContentView: View { canEdit: !store.isBusy, crop: { cropItem = item }, rotate: { store.scheduleRotation(item, rotation: $0) }, - mergeModeTitle: store.mergeModeTitle + mergeModeTitle: store.mergeModeTitle, + isCancelling: store.isCancelling, + isMergeVerifying: store.isVerifyingMerge ) + .id("\(item.id.uuidString)-\(appLanguageIdentifier)") if index < store.items.count - 1 { Divider().overlay(BrandColor.stroke) } @@ -691,22 +722,23 @@ struct ContentView: View { } private var footer: some View { - HStack(spacing: 14) { + HStack(spacing: 10) { VStack(alignment: .leading, spacing: 7) { Text(footerStatus) .font(AppFont.captionMedium) .foregroundStyle(BrandColor.textPrimary) .lineLimit(1) .truncationMode(.tail) - .frame(width: 340, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .help(footerStatus) if store.isRunning, let progress = store.overallProgress { - ProgressView(value: progress).tint(BrandColor.blue).frame(width: 340) + ProgressView(value: progress).tint(BrandColor.blue).frame(maxWidth: .infinity) } else if store.isRunning { - ProgressView().frame(width: 340, alignment: .leading) + ProgressView().frame(maxWidth: .infinity, alignment: .leading) } else if store.isMerging, let progress = store.mergeProgress { - ProgressView(value: progress).tint(BrandColor.blue).frame(width: 340) + ProgressView(value: progress).tint(BrandColor.blue).frame(maxWidth: .infinity) } else if store.isMerging { - ProgressView().frame(width: 340, alignment: .leading) + ProgressView().frame(maxWidth: .infinity, alignment: .leading) } if !store.isBusy { HStack(spacing: 7) { @@ -727,44 +759,69 @@ struct ContentView: View { } .font(AppFont.caption) .foregroundStyle(BrandColor.textSecondary) + .lineLimit(1) } } - Spacer() + // Keep the summary inside the content inset at the 980 pt minimum + // window width; a fixed 340 pt column pushed the whole footer out. + .frame(minWidth: 160, idealWidth: 260, maxWidth: 340, alignment: .leading) + .layoutPriority(-1) + Spacer(minLength: 0) Button(L10n.text("清空列表")) { store.clearQueue() } - .buttonStyle(AppActionButtonStyle(primary: false, minWidth: 96, height: 36)) + .buttonStyle(AppActionButtonStyle(primary: false, minWidth: 96, height: 36, horizontalPadding: 10)) + .fixedSize(horizontal: true, vertical: false) .disabled(store.isBusy || store.items.isEmpty) Button(L10n.format("清理已完成(%d)", completedCount)) { store.clearCompleted() } - .buttonStyle(AppActionButtonStyle(primary: false, minWidth: 124, height: 36)).disabled(!store.hasCompleted) + .buttonStyle(AppActionButtonStyle(primary: false, minWidth: 124, height: 36, horizontalPadding: 10)) + .fixedSize(horizontal: true, vertical: false) + .disabled(!store.hasCompleted) Button(L10n.text("重排失败项")) { store.retryIncomplete() } - .buttonStyle(AppActionButtonStyle(primary: false, minWidth: 108, height: 36)).disabled(store.isBusy || !store.hasIncomplete) + .buttonStyle(AppActionButtonStyle(primary: false, minWidth: 108, height: 36, horizontalPadding: 10)) + .fixedSize(horizontal: true, vertical: false) + .disabled(store.isBusy || !store.hasIncomplete) Button(L10n.format("合并(%d)", store.mergeDisplayCount)) { store.mergeWaitingItems() } - .buttonStyle(AppActionButtonStyle(primary: false, minWidth: 96, height: 36)) + .buttonStyle(AppActionButtonStyle(primary: false, minWidth: 96, height: 36, horizontalPadding: 10)) + .fixedSize(horizontal: true, vertical: false) .disabled(!store.canMerge) - if store.isRunning { - Button(L10n.text("取消任务")) { store.cancel() } - .buttonStyle(AppActionButtonStyle(primary: false, minWidth: 96, height: 36)) + if store.isBusy { + Button(L10n.text(store.isCancelling ? "正在取消…" : "取消任务")) { store.cancel() } + .buttonStyle(AppActionButtonStyle(primary: false, minWidth: 96, height: 36, horizontalPadding: 10)) + .fixedSize(horizontal: true, vertical: false) + .disabled(store.isCancelling) } else { Button(L10n.text(store.isMerging ? "正在合并" : "开始转换")) { store.start() } - .buttonStyle(AppActionButtonStyle(primary: true, minWidth: 112, height: 36)) + .buttonStyle(AppActionButtonStyle(primary: true, minWidth: 112, height: 36, horizontalPadding: 10)) + .fixedSize(horizontal: true, vertical: false) .disabled(store.isBusy || !store.hasWaiting) } } + .padding(.horizontal, 14) .padding(.top, 14) .frame(minHeight: 58) } private var completedCount: Int { store.items.reduce(into: 0) { if case .complete = $1.state { $0 += 1 } } } private var footerStatus: String { + if store.isCancelling { return L10n.text("正在停止转换并清理临时文件") } if store.isMerging { + if store.isVerifyingMerge { return L10n.text("正在验证输出") } if let progress = store.mergeProgress { return L10n.format("正在%@ %d 个片段 · %d%%", store.mergeModeTitle, store.mergeDisplayCount, Int(progress * 100)) } return L10n.text("正在检查片段兼容性") } - guard store.isRunning else { return store.items.isEmpty ? L10n.text("准备就绪") : L10n.format("已添加 %d 个视频", store.items.count) } - let details = [store.activeBatchProgressLabel, store.statusText.isEmpty ? nil : store.statusText] - .compactMap { $0 } - let detail = details.isEmpty ? L10n.text("处理中") : details.joined(separator: " · ") - if let progress = store.overallProgress { return L10n.format("正在转换 · %d%% · %@", Int(progress * 100), detail) } - return L10n.format("正在转换 · %@", detail) + guard store.isRunning else { + if store.items.isEmpty { return L10n.text("准备就绪") } + if store.hasWaiting { return L10n.format("已添加 %d 个视频", store.items.count) } + let failures = store.items.filter { if case .failed = $0.state { return true }; return false }.count + let cancelled = store.items.filter { if case .cancelled = $0.state { return true }; return false }.count + if failures > 0 || cancelled > 0 { + return L10n.format("完成 %d · 失败 %d · 取消 %d", completedCount, failures, cancelled) + } + if completedCount == store.items.count { return L10n.text("转换完成") } + return L10n.format("已添加 %d 个视频", store.items.count) + } + let phase = store.items.first { if case .running = $0.state { return true }; return false }?.phase?.title ?? L10n.text("正在转换") + let stage = store.overallProgress.map { L10n.format("%@ · %d%%", phase, Int($0 * 100)) } ?? phase + return [stage, store.activeBatchProgressLabel].compactMap { $0 }.joined(separator: " · ") } } @@ -796,6 +853,7 @@ private struct AppActionButtonStyle: ButtonStyle { let primary: Bool let minWidth: CGFloat let height: CGFloat + var horizontalPadding: CGFloat = 14 @Environment(\.isEnabled) private var isEnabled func makeBody(configuration: Configuration) -> some View { @@ -803,7 +861,7 @@ private struct AppActionButtonStyle: ButtonStyle { .font(AppFont.medium) .foregroundStyle(primary ? Color.white : (isEnabled ? BrandColor.textPrimary : BrandColor.textSecondary)) .lineLimit(1) - .padding(.horizontal, 14) + .padding(.horizontal, horizontalPadding) .frame(minWidth: minWidth, minHeight: height) .background(background(isPressed: configuration.isPressed), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay { @@ -1034,6 +1092,8 @@ private struct QueueRow: View { let crop: () -> Void let rotate: (VideoRotation?) -> Void let mergeModeTitle: String + let isCancelling: Bool + let isMergeVerifying: Bool var body: some View { HStack(spacing: 10) { @@ -1072,6 +1132,7 @@ private struct QueueRow: View { VStack(alignment: .leading, spacing: 2) { Text(L10n.text("已完成")).font(AppFont.rowTitle).foregroundStyle(BrandColor.success) Text(output.lastPathComponent).font(AppFont.caption).foregroundStyle(BrandColor.textSecondary).lineLimit(1) + mediaNotice } } case .running(let progress): @@ -1081,29 +1142,20 @@ private struct QueueRow: View { Text(runningTitle(progress)) .font(AppFont.rowTitle) .foregroundStyle(BrandColor.blue) - if let progress { ProgressView(value: progress).tint(BrandColor.blue).frame(width: 150) } + if isCancelling || item.phase == .verifying { ProgressView().frame(width: 150, alignment: .leading) } + else if let progress { ProgressView(value: progress).tint(BrandColor.blue).frame(width: 150) } else { ProgressView().frame(width: 150, alignment: .leading) } - if let notice = item.mediaHandling { - Text(notice.summary) - .font(AppFont.caption) - .foregroundStyle(BrandColor.textSecondary) - .lineLimit(1) - if !notice.warnings.isEmpty { - Text(notice.warnings.joined(separator: " · ")) - .font(AppFont.caption) - .foregroundStyle(BrandColor.warning) - .lineLimit(2) - } - } + if !isCancelling { mediaNotice } } } case .merging(let progress): HStack(spacing: 9) { StatusMark(state: item.state) VStack(alignment: .leading, spacing: 5) { - Text(progress == nil ? L10n.text("等待合并") : L10n.format("正在%@ · %d%%", mergeModeTitle, Int((progress ?? 0) * 100))) + Text(isCancelling ? L10n.text("正在取消…") : (isMergeVerifying ? L10n.text("正在验证输出") : (progress == nil ? L10n.text("等待合并") : L10n.format("正在%@ · %d%%", mergeModeTitle, Int((progress ?? 0) * 100))))) .font(AppFont.rowTitle).foregroundStyle(BrandColor.blue) - if let progress { ProgressView(value: progress).tint(BrandColor.blue).frame(width: 150) } + if isCancelling || isMergeVerifying { ProgressView().frame(width: 150, alignment: .leading) } + else if let progress { ProgressView(value: progress).tint(BrandColor.blue).frame(width: 150) } } } case .waiting: @@ -1111,7 +1163,7 @@ private struct QueueRow: View { HStack(spacing: 9) { StatusMark(state: item.state) VStack(alignment: .leading, spacing: 2) { - Text(pendingTitle).font(AppFont.rowTitle).foregroundStyle(BrandColor.warning) + Text(pendingTitle).font(AppFont.rowTitle).foregroundStyle(BrandColor.blue) Text(pendingDetail).font(AppFont.caption).foregroundStyle(BrandColor.textSecondary) } } @@ -1127,20 +1179,40 @@ private struct QueueRow: View { Text(failure.reason) .font(AppFont.captionMedium) .foregroundStyle(.red) - .lineLimit(1) + .lineLimit(2) + .help(failure.reason) Text(failure.solution) .font(AppFont.caption) .foregroundStyle(BrandColor.textSecondary) - .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) + .help(failure.solution) } .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) } case .cancelled: HStack(spacing: 9) { StatusMark(state: item.state); Text(L10n.text("已取消")).font(AppFont.rowTitle).foregroundStyle(BrandColor.textSecondary) } } } + @ViewBuilder private var mediaNotice: some View { + if let notice = item.mediaHandling { + if !notice.summary.isEmpty { + Text(notice.summary) + .font(AppFont.caption) + .foregroundStyle(BrandColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + ForEach(notice.warnings, id: \.self) { warning in + Text(warning) + .font(AppFont.caption) + .foregroundStyle(BrandColor.warning) + .fixedSize(horizontal: false, vertical: true) + } + } + } + private var rowBackground: Color { if case .running = item.state { return BrandColor.blue.opacity(0.075) } if case .merging = item.state { return BrandColor.blue.opacity(0.075) } @@ -1148,9 +1220,11 @@ private struct QueueRow: View { } private func runningTitle(_ progress: Double?) -> String { + if isCancelling { return L10n.text("正在取消…") } let phase = item.phase?.title ?? L10n.text("正在转换") + if item.phase == .verifying { return phase } if let progress { return L10n.format("%@ · %d%%", phase, Int(progress * 100)) } - return L10n.format("%@ · %@", phase, L10n.text("处理中")) + return phase } private func timeText(_ seconds: Double) -> String { @@ -1349,6 +1423,23 @@ private struct EditorActionButtonStyle: ButtonStyle { } } +private struct EditorLoadFailure: View { + let message: String + + var body: some View { + VStack(spacing: 8) { + Text(message).font(AppFont.rowTitle).foregroundStyle(.red) + Text(L10n.text("请确认文件可播放;也可先用「智能转换」转为 MP4,再重新添加。")) + .font(AppFont.body) + .foregroundStyle(BrandColor.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 24) + .accessibilityElement(children: .combine) + } +} + private struct TrimEditor: View { let source: URL let initialRange: ClipRange? @@ -1363,6 +1454,7 @@ private struct TrimEditor: View { @State private var player: AVPlayer? @State private var loadError: String? @State private var isApplyingFrameStep = false + @State private var inspectionTask: Task? var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -1397,9 +1489,7 @@ private struct TrimEditor: View { .background(BrandColor.selectHover, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 9, style: .continuous).stroke(BrandColor.selectHoverStroke.opacity(0.42), lineWidth: 1) } } else if let loadError { - Text(loadError) - .font(AppFont.body) - .foregroundStyle(BrandColor.textSecondary) + EditorLoadFailure(message: loadError) .frame(maxWidth: .infinity, minHeight: 260) } else { HStack(spacing: 10) { ProgressView(); Text(L10n.text("正在加载视频预览")).foregroundStyle(BrandColor.textSecondary) } @@ -1423,7 +1513,7 @@ private struct TrimEditor: View { // height makes macOS animate from an incorrect vertical origin. .frame(width: 760, height: 540, alignment: .top) .onAppear { loadDuration() } - .onDisappear { player?.pause() } + .onDisappear { inspectionTask?.cancel(); player?.pause() } .onChange(of: start) { value in startText = timeText(value) } .onChange(of: end) { value in endText = timeText(value) } } @@ -1483,10 +1573,13 @@ private struct TrimEditor: View { } private func loadDuration() { - Task { + inspectionTask?.cancel() + inspectionTask = Task { player = AVPlayer(url: source) - guard let media = await EditorMediaProbe.inspect(source: source) else { - loadError = L10n.text("无法读取视频信息,请确认文件可播放。") + let media = await EditorMediaProbe.inspect(source: source) + guard !Task.isCancelled else { return } + guard let media else { + loadError = L10n.text("无法加载剪辑预览") return } let seconds = media.duration @@ -1622,6 +1715,7 @@ private struct CropEditor: View { @State private var widthText = "0" @State private var heightText = "0" @State private var loadError: String? + @State private var inspectionTask: Task? var body: some View { // Scheme A keeps every editing control in one vertical flow: preview, @@ -1695,21 +1789,27 @@ private struct CropEditor: View { .background(BrandColor.selectSurface, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 9, style: .continuous).stroke(BrandColor.selectStroke, lineWidth: 1) } } else if let loadError { - Text(loadError) - .font(AppFont.body) - .foregroundStyle(BrandColor.textSecondary) + EditorLoadFailure(message: loadError) .frame(maxWidth: .infinity, minHeight: 280) } else { HStack(spacing: 10) { ProgressView(); Text(L10n.text("正在加载裁切预览")).foregroundStyle(BrandColor.textSecondary) } .frame(maxWidth: .infinity, minHeight: 280) } + if sourceWidth <= 0 || sourceHeight <= 0 { + HStack { + Spacer() + Button(L10n.text("取消")) { dismiss() } + .buttonStyle(EditorActionButtonStyle(primary: false)) + .keyboardShortcut(.cancelAction) + } + } } .padding(20) // Match the initial loading state to the ready editor so the sheet never // jumps upward while metadata and the first frame are being prepared. .frame(width: 760, height: 570, alignment: .top) .onAppear { loadMedia() } - .onDisappear { player?.pause() } + .onDisappear { inspectionTask?.cancel(); player?.pause() } } private func cropField(_ title: String, text: Binding) -> some View { @@ -1730,12 +1830,15 @@ private struct CropEditor: View { } private func loadMedia() { - Task { + inspectionTask?.cancel() + inspectionTask = Task { let previewPlayer = AVPlayer(url: source) previewPlayer.actionAtItemEnd = .pause player = previewPlayer - guard let media = await EditorMediaProbe.inspect(source: source) else { - loadError = L10n.text("无法读取视频尺寸,请确认文件可播放。") + let media = await EditorMediaProbe.inspect(source: source) + guard !Task.isCancelled else { return } + guard let media else { + loadError = L10n.text("无法加载裁切预览") return } sourceWidth = media.width @@ -1973,7 +2076,7 @@ private struct CropAspectButtonStyle: ButtonStyle { } } -private struct EditorMediaInfo: Sendable { +struct EditorMediaInfo: Sendable { let duration: Double let width: Int let height: Int @@ -1985,7 +2088,7 @@ private struct EditorMediaInfo: Sendable { /// Editors must never wait on AVFoundation's lazy duration loading. FFprobe is /// already the conversion engine's source of truth and returns the dimensions /// and duration in one bounded process call. -private enum EditorMediaProbe { +enum EditorMediaProbe { private struct Response: Decodable { let streams: [Stream] let format: Format? @@ -2005,38 +2108,27 @@ private enum EditorMediaProbe { let duration: String? } - static func inspect(source: URL) async -> EditorMediaInfo? { + static func inspect(source: URL, using probeOverride: URL? = nil) async -> EditorMediaInfo? { let candidates = ["/opt/homebrew/bin/ffprobe", "/usr/local/bin/ffprobe", "/opt/local/bin/ffprobe", "/usr/bin/ffprobe"] - guard let binary = candidates + guard let binary = probeOverride ?? candidates .map(URL.init(fileURLWithPath:)) .first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) else { return nil } - return await Task.detached(priority: .userInitiated) { - let process = Process() - let output = Pipe() - process.executableURL = binary - process.arguments = ["-v", "error", "-show_entries", "stream=codec_type,width,height,r_frame_rate:format=duration", "-of", "json", source.path] - process.standardOutput = output - process.standardError = Pipe() - do { - try process.run() - // A damaged network file must become an actionable error, never an - // editor sheet that spins indefinitely. - DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 5) { - if process.isRunning { process.terminate() } - } - process.waitUntilExit() - guard process.terminationStatus == 0 else { return nil } - let response = try JSONDecoder().decode(Response.self, from: output.fileHandleForReading.readDataToEndOfFile()) - guard let video = response.streams.first(where: { $0.codecType == "video" }), - 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 } - return EditorMediaInfo(duration: duration, width: width, height: height, frameRate: parseFrameRate(video.frameRate) ?? 30) - } catch { - return nil - } - }.value + // Editors share the same bounded, cancellable pipe owner as conversion. + // Closing a loading sheet cancels inspection rather than leaving a + // 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], + 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 width = video.width, let height = video.height, + width > 0, height > 0, + let duration = Double(response.format?.duration ?? ""), duration.isFinite, duration > 0 else { return nil } + return EditorMediaInfo(duration: duration, width: width, height: height, frameRate: parseFrameRate(video.frameRate) ?? 30) } private static func parseFrameRate(_ value: String?) -> Double? { @@ -2088,9 +2180,9 @@ private struct CropPlaybackControls: View { var body: some View { HStack(spacing: 12) { - transportButton(icon: .playerSkipBack) { move(by: -5) } - transportButton(icon: isPlaying ? .playerPause : .playerPlay) { togglePlayback() } - transportButton(icon: .playerSkipForward) { move(by: 5) } + transportButton(icon: .playerSkipBack, title: "后退 5 秒") { move(by: -5) } + transportButton(icon: isPlaying ? .playerPause : .playerPlay, title: isPlaying ? "暂停" : "播放") { togglePlayback() } + transportButton(icon: .playerSkipForward, title: "前进 5 秒") { move(by: 5) } Text(timeText(currentTime)) .font(.system(size: 10, weight: .medium, design: .monospaced)) @@ -2098,6 +2190,8 @@ private struct CropPlaybackControls: View { .frame(width: 48, alignment: .trailing) Slider(value: Binding(get: { currentTime }, set: { seek(to: $0) }), in: 0...max(0.1, duration)) .tint(BrandColor.blue) + .accessibilityLabel(L10n.text("播放进度")) + .help(L10n.text("播放进度")) Text(timeText(duration)) .font(.system(size: 10, weight: .medium, design: .monospaced)) .foregroundStyle(BrandColor.textSecondary) @@ -2111,6 +2205,8 @@ private struct CropPlaybackControls: View { }), in: 0...1) .tint(BrandColor.blue) .frame(width: 68) + .accessibilityLabel(L10n.text("音量")) + .help(L10n.text("音量")) } .padding(.horizontal, 14) .frame(height: 40) @@ -2120,13 +2216,15 @@ private struct CropPlaybackControls: View { .onDisappear { removeTimeObserver() } } - private func transportButton(icon: TablerIconName, action: @escaping () -> Void) -> some View { + private func transportButton(icon: TablerIconName, title: String, action: @escaping () -> Void) -> some View { Button(action: action) { TablerIcon(name: icon, color: BrandColor.textPrimary) .frame(width: 16, height: 16) .frame(width: 20, height: 24) } .buttonStyle(.plain) + .help(L10n.text(title)) + .accessibilityLabel(L10n.text(title)) } private func installTimeObserver() { diff --git a/Sources/FastConvert/SmartEnhanceExporter.swift b/Sources/FastConvert/SmartEnhanceExporter.swift index dc4daa4..55824e1 100644 --- a/Sources/FastConvert/SmartEnhanceExporter.swift +++ b/Sources/FastConvert/SmartEnhanceExporter.swift @@ -224,7 +224,7 @@ final class SmartEnhanceExporter { #endif } - private enum ExportError: LocalizedError { + enum ExportError: LocalizedError { case unavailable case invalidSource case reader(String) diff --git a/Tests/FastConvertTests/ConversionFailureLocalizationTests.swift b/Tests/FastConvertTests/ConversionFailureLocalizationTests.swift new file mode 100644 index 0000000..1379aa5 --- /dev/null +++ b/Tests/FastConvertTests/ConversionFailureLocalizationTests.swift @@ -0,0 +1,116 @@ +import Foundation +import XCTest +@testable import FastConvert + +final class ConversionFailureLocalizationTests: XCTestCase { + func testStallAndHardwareFailuresKeepTheSameClassificationInEveryLanguage() { + let defaults = UserDefaults.standard + let domainName = "NSArgumentDomain" + let originalDomain = defaults.volatileDomain(forName: domainName) + defer { defaults.setVolatileDomain(originalDomain, forName: domainName) } + + for language in AppLanguage.allCases { + var domain = originalDomain + domain["appLanguage"] = language.rawValue + defaults.setVolatileDomain(domain, forName: domainName) + + let stalled = ConverterStore.failure(from: "MP4Flow:stalled") + XCTAssertEqual(stalled.reasonKey, "转换长时间无进度,已安全停止", language.rawValue) + XCTAssertEqual(stalled.solutionKey, "请确认视频可播放、磁盘可用后重试;仍失败请使用「最稳妥转换」。", language.rawValue) + XCTAssertEqual(stalled.diagnostic, "ffmpeg:stalled", language.rawValue) + XCTAssertEqual(stalled.reason, L10n.text(stalled.reasonKey)) + XCTAssertEqual(stalled.solution, L10n.text(stalled.solutionKey)) + + for stderr in [ + "Unknown encoder 'h264_videotoolbox'", + "[hevc_videotoolbox] Encoder not available", + "VTCompressionSessionCreate failed with error -12903" + ] { + let message = ConverterStore.ffmpegFailureMessage(stderr, code: 1) + XCTAssertEqual(message, "MP4Flow:hardware-encoder", language.rawValue) + let hardware = ConverterStore.failure(from: message) + XCTAssertEqual(hardware.reasonKey, "硬件编码不可用", language.rawValue) + XCTAssertEqual(hardware.solutionKey, "请重试;仍失败请使用「最稳妥转换」。", language.rawValue) + XCTAssertEqual(hardware.diagnostic, "ffmpeg:hardware-encoder", language.rawValue) + XCTAssertEqual(hardware.reason, L10n.text(hardware.reasonKey)) + XCTAssertEqual(hardware.solution, L10n.text(hardware.solutionKey)) + } + let software = ConverterStore.ffmpegFailureMessage("Unknown encoder 'libx264'", code: 1) + XCTAssertNotEqual(software, "MP4Flow:hardware-encoder", "A missing software encoder must not be described as hardware failure") + let disk = ConverterStore.ffmpegFailureMessage("[h264_videotoolbox] Error submitting frame\nNo space left on device", code: 1) + XCTAssertEqual(disk, "MP4Flow:disk-full", "A terminal I/O failure must keep priority over hardware markers") + } + } + + func testKnownMediaToolAndContainerErrorsStayTypedInEveryLanguage() { + let cases: [(stderr: String?, sentinel: String, reason: String, solution: String, diagnostic: String)] = [ + ( + "Invalid data found when processing input: /private/customer/video.mp4", + "MP4Flow:unreadable-media", + "无法读取此视频的媒体数据", + "请确认文件可播放,或使用「智能转换」生成新文件后重试。", + "ffmpeg:unreadable-media" + ), + ( + "moov atom not found", + "MP4Flow:unreadable-media", + "无法读取此视频的媒体数据", + "请确认文件可播放,或使用「智能转换」生成新文件后重试。", + "ffmpeg:unreadable-media" + ), + ( + "Unknown encoder 'libx264'", + "MP4Flow:software-encoder", + "软件编码器不可用", + "请更新或重新安装 FFmpeg,或改用「智能转换」后重试。", + "ffmpeg:software-encoder" + ), + ( + "Could not find tag for codec pcm_s16le in stream #1, codec not currently supported in container", + "MP4Flow:container-incompatible", + "此视频流无法直接写入 MP4", + "请使用「智能转换」重新编码后重试;仍失败请使用「最稳妥转换」。", + "ffmpeg:container-incompatible" + ), + ( + nil, + "MP4Flow:launch-failed", + "无法启动转换工具", + "请在「环境体检」检查 FFmpeg,修复安装或权限后重试。", + "ffmpeg:launch-failed" + ), + ( + "Unexpected muxer diagnostic involving /private/customer/video.mp4", + "MP4Flow:conversion-failed", + "转换未完成", + "请重试;仍失败请使用「最稳妥转换」。", + "ffmpeg:failed" + ) + ] + let defaults = UserDefaults.standard + let domainName = "NSArgumentDomain" + let originalDomain = defaults.volatileDomain(forName: domainName) + defer { defaults.setVolatileDomain(originalDomain, forName: domainName) } + for language in AppLanguage.allCases { + var domain = originalDomain + domain["appLanguage"] = language.rawValue + defaults.setVolatileDomain(domain, forName: domainName) + for sample in cases { + let message = sample.stderr.map { ConverterStore.ffmpegFailureMessage($0, code: 1) } ?? sample.sentinel + XCTAssertEqual(message, sample.sentinel, language.rawValue) + let failure = ConverterStore.failure(from: message) + XCTAssertEqual(failure.reasonKey, sample.reason, language.rawValue) + XCTAssertEqual(failure.solutionKey, sample.solution, language.rawValue) + XCTAssertEqual(failure.diagnostic, sample.diagnostic, language.rawValue) + XCTAssertFalse([failure.reason, failure.solution, failure.diagnostic ?? ""].joined().contains("customer")) + XCTAssertEqual(failure.reason, L10n.text(sample.reason)) + XCTAssertEqual(failure.solution, L10n.text(sample.solution)) + } + XCTAssertEqual( + ConverterStore.ffmpegFailureMessage("Could not find tag for codec pcm_s16le\nPermission denied", code: 1), + "MP4Flow:permission", + "Permission failures take priority over codec/container context" + ) + } + } +} diff --git a/Tests/FastConvertTests/ConversionPlanningTests.swift b/Tests/FastConvertTests/ConversionPlanningTests.swift new file mode 100644 index 0000000..e85631a --- /dev/null +++ b/Tests/FastConvertTests/ConversionPlanningTests.swift @@ -0,0 +1,214 @@ +import Foundation +import XCTest +@testable import FastConvert + +final class ConversionPlanningTests: XCTestCase { + func testResizeUsesDisplayedOrientationAndCropDimensions() { + var info = fixtureInfo() + info.displayGeometry = VideoGeometry.displayed(width: 1280, height: 720, sampleAspectRatio: "1:1", rotation: 90) + XCTAssertEqual(info.displayGeometry?.width, 720) + XCTAssertEqual(info.displayGeometry?.height, 1280) + XCTAssertTrue(OutputResolution.p720.requiresReencode(sourceHeight: info.displayGeometry?.height), "Encoded height 720 is not a no-op for a portrait display matrix") + XCTAssertEqual(info.geometry(crop: CropRect(x: 0, y: 0, width: 600, height: 400), rotation: .clockwise)?.height, 600) + XCTAssertNil(VideoGeometry.displayed(width: 1280, height: 720, sampleAspectRatio: "1:1", rotation: 45)) + XCTAssertEqual(VideoGeometry.displayed(width: 1280, height: 720, sampleAspectRatio: "4:3", rotation: nil)?.canSkipUnchangedSize, false) + XCTAssertEqual(VideoGeometry.displayed(width: 1279, height: 720, sampleAspectRatio: "1:1", rotation: nil)?.canSkipUnchangedSize, false) + XCTAssertNil(OutputResolution.p720.filter(sourceHeight: 720, quality: .balanced, useVideoToolbox: true, canSkipUnchangedSize: true)) + XCTAssertNotNil(OutputResolution.p720.filter(sourceHeight: 720, quality: .balanced, useVideoToolbox: true, canSkipUnchangedSize: false)) + } + + func testEncodingClockRequiresPositiveBoundedRational() { + var info = fixtureInfo() + info.timeBase = "1/90000" + XCTAssertEqual(info.encodingTimeBase, "1/90000") + for invalid in ["", "N/A", "1/0", "0/90000", "-1/30", "1/999999999999", "1/30/2", "demux"] { + info.timeBase = invalid + XCTAssertNil(info.encodingTimeBase, invalid) + } + } + + @MainActor + func testSameSizeSmartConversionCopiesVideoButExplicitSoftwareStillEncodes() async throws { + let tools = try mediaTools() + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("720p.mp4") + try run(tools.ffmpeg, ["-v", "error", "-f", "lavfi", "-i", "testsrc2=size=160x720:rate=6:duration=1", "-c:v", "libx264", "-preset", "ultrafast", "-crf", "30", source.path]) + let sourceHash = try copiedVideoHash(source, ffmpeg: tools.ffmpeg) + + let smart = ConverterStore() + smart.outputResolution = .p720 + smart.outputDirectory = directory + smart.items = [ConversionItem(source: source)] + smart.start() + let copied = try await completedOutput(smart) + XCTAssertEqual(try copiedVideoHash(copied, ffmpeg: tools.ffmpeg), sourceHash, "An unchanged smart resize must not spend time re-encoding") + + let software = ConverterStore() + software.preset = .h264Software + software.outputResolution = .p720 + software.outputDirectory = directory + software.items = [ConversionItem(source: source)] + software.start() + let encoded = try await completedOutput(software) + XCTAssertNotEqual(try copiedVideoHash(encoded, ffmpeg: tools.ffmpeg), sourceHash, "An explicit software/quality choice must retain its encoding semantics") + } + + @MainActor + func testSmartConvertsOnlyIncompatibleAudioAndKeepsVideoBytes() async throws { + let tools = try mediaTools() + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("H264 and PCM.mov") + try run(tools.ffmpeg, ["-v", "error", "-f", "lavfi", "-i", "testsrc2=size=160x720:rate=12:duration=1", "-f", "lavfi", "-i", "sine=frequency=660:sample_rate=48000:duration=1", "-c:v", "libx264", "-preset", "ultrafast", "-crf", "30", "-c:a", "pcm_s16le", "-shortest", source.path]) + let sourceHash = try copiedVideoHash(source, ffmpeg: tools.ffmpeg) + + let smart = ConverterStore() + smart.outputResolution = .p720 + smart.outputDirectory = directory + smart.items = [ConversionItem(source: source)] + smart.start() + let output = try await completedOutput(smart) + XCTAssertEqual(try copiedVideoHash(output, ffmpeg: tools.ffmpeg), sourceHash, "Non-AAC audio must not force compatible H.264 video through an encoder") + let audio = try capture(tools.ffprobe, ["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name", "-of", "csv=p=0", output.path]) + XCTAssertEqual(audio.trimmingCharacters(in: .whitespacesAndNewlines), "aac") + let durationText = try capture(tools.ffprobe, ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", output.path]) + let duration = try XCTUnwrap(Double(durationText.trimmingCharacters(in: .whitespacesAndNewlines))) + XCTAssertEqual(duration, 1, accuracy: 0.1) + try run(tools.ffmpeg, ["-v", "error", "-i", output.path, "-map", "0:v:0", "-map", "0:a:0", "-f", "null", "-"]) + + for (preset, resolution) in [(ConversionPreset.h264Software, OutputResolution.p720), (.smartMP4, .p480)] { + let encoded = ConverterStore() + encoded.preset = preset + encoded.outputResolution = resolution + encoded.outputDirectory = directory + encoded.items = [ConversionItem(source: source)] + encoded.start() + let encodedOutput = try await completedOutput(encoded) + XCTAssertNotEqual(try copiedVideoHash(encodedOutput, ffmpeg: tools.ffmpeg), sourceHash, "Explicit encoding and actual resizing must still encode video") + } + } + + @MainActor + func testVariableFrameRateTranscodePreservesEveryPresentationTimestamp() async throws { + let tools = try mediaTools() + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("variable timing.mp4") + try run(tools.ffmpeg, ["-v", "error", "-f", "lavfi", "-i", "testsrc2=size=160x96:rate=30:duration=2", "-vf", "settb=1/90000,setpts=PTS+270*sin(N)", "-enc_time_base:v", "1/90000", "-c:v", "libx264", "-preset", "ultrafast", "-crf", "24", source.path]) + let sourceTimes = try frameTimes(source, ffprobe: tools.ffprobe) + XCTAssertEqual(sourceTimes.count, 60) + let intervals = zip(sourceTimes.dropFirst(), sourceTimes).map(-) + XCTAssertGreaterThan((intervals.max() ?? 0) - (intervals.min() ?? 0), 0.003, "The fixture must actually have irregular timestamps") + + for preset in [ConversionPreset.h264Software, .h264Hardware] { + let store = ConverterStore() + store.preset = preset + store.outputDirectory = directory + let item = ConversionItem(source: source) + store.items = [item] + if preset == .h264Hardware { store.scheduleRotation(item, rotation: .clockwise) } + store.start() + let output = try await completedOutput(store) + let outputTimes = try frameTimes(output, ffprobe: tools.ffprobe) + XCTAssertEqual(outputTimes.count, sourceTimes.count) + for (sourceTime, outputTime) in zip(sourceTimes, outputTimes) { + XCTAssertEqual(outputTime, sourceTime, accuracy: 0.000012, "A VFR frame must retain its original presentation time: \(preset)") + } + if preset == .h264Hardware { + let dimensions = try capture(tools.ffprobe, ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", output.path]) + XCTAssertEqual(dimensions.trimmingCharacters(in: .whitespacesAndNewlines), "96,160", "Rotation must preserve the source clock while swapping output dimensions") + } + } + } + + @MainActor + func testLateAccurateTrimMatchesReferenceFrameAndCopiedAudio() async throws { + let tools = try mediaTools() + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("late trim.mp4") + try run(tools.ffmpeg, ["-v", "error", "-f", "lavfi", "-i", "testsrc2=size=192x128:rate=30:duration=8", "-f", "lavfi", "-i", "sine=frequency=660:sample_rate=48000:duration=8", "-c:v", "libx264", "-preset", "ultrafast", "-g", "90", "-crf", "24", "-c:a", "aac", "-shortest", source.path]) + + let store = ConverterStore() + store.preset = .h264Software + store.outputDirectory = directory + let item = ConversionItem(source: source) + store.items = [item] + store.scheduleTrim(item, start: 6.123, end: 7.456) + // Force a pixel edit, since a compatible trim alone deliberately copies. + store.scheduleCrop(item, crop: CropRect(x: 0, y: 0, width: 192, height: 128)) + store.start() + let output = try await completedOutput(store) + let reference = directory.appendingPathComponent("reference.mp4") + try run(tools.ffmpeg, ["-v", "error", "-i", source.path, "-ss", "6.123", "-to", "7.456", "-vf", "crop=192:128:0:0", "-enc_time_base:v", "1/15360", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", "-an", reference.path]) + let actualFrame = try capture(tools.ffmpeg, ["-v", "error", "-i", output.path, "-map", "0:v:0", "-frames:v", "1", "-f", "md5", "-"]) + let referenceFrame = try capture(tools.ffmpeg, ["-v", "error", "-i", reference.path, "-map", "0:v:0", "-frames:v", "1", "-f", "md5", "-"]) + XCTAssertEqual(actualFrame, referenceFrame, "Fast seeking must retain the first accurately selected video frame") + let actualAudio = try capture(tools.ffmpeg, ["-v", "error", "-i", output.path, "-t", "1.1", "-vn", "-c:a", "pcm_s16le", "-f", "md5", "-"]) + let referenceAudio = try capture(tools.ffmpeg, ["-v", "error", "-ss", "6.123", "-i", source.path, "-t", "1.1", "-vn", "-c:a", "pcm_s16le", "-f", "md5", "-"]) + XCTAssertEqual(actualAudio, referenceAudio, "Copied AAC must discard pre-roll and remain aligned to the requested start") + } + + private func fixtureInfo() -> VideoInfo { + VideoInfo(duration: 1, videoRate: 1_000_000, audioRate: nil, width: 1280, height: 720, hasVideo: true, videoCodec: "h264", audioCodec: nil, frameRate: "30/1", pixelFormat: "yuv420p", audioSampleRate: nil, audioChannels: nil, audioTrackCount: 0, subtitleTrackCount: 0, colorTransfer: nil, probeSucceeded: true) + } + + private func mediaTools() throws -> (ffmpeg: URL, ffprobe: URL) { + guard let path = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"].first(where: { FileManager.default.isExecutableFile(atPath: $0) }) else { + throw XCTSkip("FFmpeg is required for synthetic media tests") + } + let ffmpeg = URL(fileURLWithPath: path) + guard try capture(ffmpeg, ["-hide_banner", "-encoders"]).contains("libx264") else { + throw XCTSkip("The synthetic regression fixture requires libx264") + } + return (ffmpeg, ffmpeg.deletingLastPathComponent().appendingPathComponent("ffprobe")) + } + + private func temporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("mp4flow-planning-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func run(_ executable: URL, _ arguments: [String]) throws { + _ = try capture(executable, arguments) + } + + private func capture(_ executable: URL, _ arguments: [String]) throws -> String { + let process = Process() + let pipe = Pipe() + process.executableURL = executable + process.arguments = arguments + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 0, "Synthetic fixture command failed: \(arguments.prefix(4))") + guard process.terminationStatus == 0 else { throw NSError(domain: "ConversionPlanningTests", code: Int(process.terminationStatus)) } + return String(decoding: data, as: UTF8.self) + } + + private func copiedVideoHash(_ source: URL, ffmpeg: URL) throws -> String { + try capture(ffmpeg, ["-v", "error", "-i", source.path, "-map", "0:v:0", "-c:v", "copy", "-f", "hash", "-"]) + } + + private func frameTimes(_ source: URL, ffprobe: URL) throws -> [Double] { + let data = try capture(ffprobe, ["-v", "error", "-select_streams", "v:0", "-show_entries", "frame=best_effort_timestamp_time", "-of", "json", source.path]) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(data.utf8)) as? [String: Any]) + let frames = try XCTUnwrap(json["frames"] as? [[String: Any]]) + return frames.compactMap { ($0["best_effort_timestamp_time"] as? String).flatMap(Double.init) } + } + + @MainActor + private func completedOutput(_ store: ConverterStore) async throws -> URL { + let deadline = Date().addingTimeInterval(30) + while store.isRunning && Date() < deadline { try await Task.sleep(for: .milliseconds(25)) } + XCTAssertFalse(store.isRunning, "The small synthetic conversion should complete") + guard case .complete(let output) = store.items[0].state else { + throw NSError(domain: "ConversionPlanningTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unexpected state: \(store.items[0].state)"]) + } + return output + } +} diff --git a/Tests/FastConvertTests/ConverterStoreTests.swift b/Tests/FastConvertTests/ConverterStoreTests.swift index 9970c5d..6fb01b1 100644 --- a/Tests/FastConvertTests/ConverterStoreTests.swift +++ b/Tests/FastConvertTests/ConverterStoreTests.swift @@ -3,6 +3,140 @@ import XCTest @testable import FastConvert final class ConverterStoreTests: XCTestCase { + /// Opt-in local benchmark of the actual queue. Media paths are supplied at + /// invocation time and never stored in the repository or printed in reports. + @MainActor + func testAuthorizedMedia720pBenchmark() async throws { + guard let input = ProcessInfo.processInfo.environment["MP4FLOW_BENCHMARK_INPUT"], !input.isEmpty else { + throw XCTSkip("Set MP4FLOW_BENCHMARK_INPUT to an authorized local video to run the full-media benchmark") + } + let ffmpeg = try availableFFmpeg() + let ffprobe = ffmpeg.deletingLastPathComponent().appendingPathComponent("ffprobe") + let source = URL(fileURLWithPath: input) + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let sourceDuration = try XCTUnwrap(Double(probeValue(source, ffprobe: ffprobe, entries: "format=duration").trimmingCharacters(in: .whitespacesAndNewlines))) + let store = ConverterStore() + store.outputResolution = .p720 + store.outputDirectory = directory + store.items = [ConversionItem(source: source)] + let started = Date() + store.start() + try await waitForQueue(store, timeout: 600) + let elapsed = Date().timeIntervalSince(started) + guard case .complete(let output) = store.items[0].state else { + return XCTFail("Authorized benchmark did not complete: \(store.items[0].state)") + } + let dimensions = try probeValue(output, ffprobe: ffprobe, entries: "stream=width,height", selectStreams: "v:0") + .trimmingCharacters(in: .whitespacesAndNewlines) + let outputDuration = try XCTUnwrap(Double(probeValue(output, ffprobe: ffprobe, entries: "format=duration").trimmingCharacters(in: .whitespacesAndNewlines))) + XCTAssertEqual(outputDuration, sourceDuration, accuracy: 0.15) + let sourceFrames = Int(try probeValue(source, ffprobe: ffprobe, entries: "stream=nb_frames", selectStreams: "v:0").trimmingCharacters(in: .whitespacesAndNewlines)) + let outputFrames = Int(try probeValue(output, ffprobe: ffprobe, entries: "stream=nb_frames", selectStreams: "v:0").trimmingCharacters(in: .whitespacesAndNewlines)) + if let sourceFrames, let outputFrames { XCTAssertEqual(outputFrames, sourceFrames) } + XCTAssertEqual(try decodeVideo(output, ffmpeg: ffmpeg), 0) + print(String(format: "Local queue benchmark: source %.3f s; elapsed %.3f s; %.2fx real time; output %@; frames %@", sourceDuration, elapsed, sourceDuration / max(elapsed, 0.001), dimensions, outputFrames.map(String.init) ?? "unknown")) + } + + func testHardwareFallbackClassifierRetriesOnlyExplicitVideoToolboxFailures() { + XCTAssertEqual( + FFmpegHardwareRetryClassifier.hint( + stderr: "Impossible to convert between filter Parsed_scale_vt_0 and output format", + usedHardwarePipeline: true, + usedHardwareEncoder: true + ), + .cpuDecodeAndFilter + ) + XCTAssertEqual( + FFmpegHardwareRetryClassifier.hint( + stderr: "Unknown encoder 'h264_videotoolbox'", + usedHardwarePipeline: true, + usedHardwareEncoder: true + ), + .softwareEncoder, + "A missing VT encoder should skip the intermediate retry with the same encoder" + ) + XCTAssertEqual( + FFmpegHardwareRetryClassifier.hint( + stderr: "VTCompressionSessionCreate failed with error -12903", + usedHardwarePipeline: false, + usedHardwareEncoder: true + ), + .softwareEncoder + ) + for stderr in [ + "Invalid data found when processing input", + "No space left on device", + "Error while decoding stream 0:0", + "FFmpeg stalled without progress" + ] { + XCTAssertNil( + FFmpegHardwareRetryClassifier.hint( + stderr: stderr, + usedHardwarePipeline: true, + usedHardwareEncoder: true + ), + "Must not replay an entire video for unrelated failure: \(stderr)" + ) + } + let mappedHardwareThenDecodeError = """ + Stream mapping: + Stream #0:0 -> #0:0 (h264 (native) -> h264 (h264_videotoolbox)) + Error while decoding stream 0:0 + """ + XCTAssertNil(FFmpegHardwareRetryClassifier.hint( + stderr: mappedHardwareThenDecodeError, + usedHardwarePipeline: true, + usedHardwareEncoder: true + )) + let mappedHardwareThenMuxError = """ + Stream mapping: + Stream #0:0 -> #0:0 (h264 (native) -> h264 (h264_videotoolbox)) + Could not write header for output file #0 (incorrect codec parameters?): Invalid argument + """ + XCTAssertNil(FFmpegHardwareRetryClassifier.hint( + stderr: mappedHardwareThenMuxError, + usedHardwarePipeline: true, + usedHardwareEncoder: true + )) + let explicitHardwareAndTerminalError = """ + [Parsed_scale_vt_0] Error applying hardware filter + No space left on device + """ + XCTAssertNil(FFmpegHardwareRetryClassifier.hint( + stderr: explicitHardwareAndTerminalError, + usedHardwarePipeline: true, + usedHardwareEncoder: true + ), "A terminal disk failure must not trigger another full encode") + let cpuPipelineThenDecodeError = """ + Stream mapping: + Stream #0:0 -> #0:0 (h264 (native) -> h264 (h264_videotoolbox)) + Error while decoding stream 0:0 + """ + XCTAssertNil(FFmpegHardwareRetryClassifier.hint( + stderr: cpuPipelineThenDecodeError, + usedHardwarePipeline: false, + usedHardwareEncoder: true + )) + let mappedHardwareThenOutputError = """ + Stream mapping: + Stream #0:0 -> #0:0 (h264 (native) -> h264 (h264_videotoolbox)) + Error initializing output stream 0:0 + """ + XCTAssertNil(FFmpegHardwareRetryClassifier.hint( + stderr: mappedHardwareThenOutputError, + usedHardwarePipeline: true, + usedHardwareEncoder: true + )) + XCTAssertNil( + FFmpegHardwareRetryClassifier.hint( + stderr: "No such filter: 'scale_vt'", + usedHardwarePipeline: false, + usedHardwareEncoder: false + ) + ) + } + @MainActor func testSoftwareConversionPublishesVerifiedMP4() async throws { let ffmpeg = try availableFFmpeg() @@ -32,6 +166,77 @@ final class ConverterStoreTests: XCTestCase { XCTAssertEqual(published.count, 1, "Repeated start must not publish a duplicate") } + @MainActor + func testRemovingWaitingItemUpdatesBatchCountAndSkipsItsConversion() async throws { + let ffmpeg = try availableFFmpeg() + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let first = directory.appendingPathComponent("first.mov") + let skipped = directory.appendingPathComponent("skipped.mov") + try makeShortVideo(at: first, ffmpeg: ffmpeg) + try FileManager.default.copyItem(at: first, to: skipped) + let store = ConverterStore() + store.preset = .h264Software + store.outputDirectory = directory + let waiting = ConversionItem(source: skipped) + store.items = [ConversionItem(source: first), waiting] + store.start() + store.remove(waiting) + XCTAssertEqual(store.activeBatchProgressLabel, L10n.format("已处理 %d/%d", 0, 1)) + try await waitForQueue(store) + guard let remaining = store.items.first, case .complete = remaining.state else { + return XCTFail("The remaining queued item should complete") + } + let outputs = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "mp4" } + XCTAssertEqual(outputs.count, 1) + XCTAssertFalse(outputs.contains(where: { $0.lastPathComponent.hasPrefix("skipped") })) + } + + @MainActor + func testDefault1080pTo720pConversionPreservesGeometryFrameRateAndAudio() async throws { + let ffmpeg = try availableFFmpeg() + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("1080p source.mp4") + guard let sourceEncoder = try availableH264Encoder(ffmpeg: ffmpeg) else { + throw XCTSkip("FFmpeg must provide libx264 or h264_videotoolbox to generate the fixture") + } + try make1080pVideo(at: source, ffmpeg: ffmpeg, encoder: sourceEncoder) + + let store = ConverterStore() + store.outputResolution = .p720 + store.outputDirectory = directory + store.items = [ConversionItem(source: source)] + store.start() + try await waitForQueue(store) + + guard case .complete(let output) = store.items[0].state else { + return XCTFail("Expected the default 1080p→720p conversion to complete; got \(store.items[0].state)") + } + let ffprobe = ffmpeg.deletingLastPathComponent().appendingPathComponent("ffprobe") + let video = try probeValue( + output, + ffprobe: ffprobe, + entries: "stream=width,height,sample_aspect_ratio,display_aspect_ratio,r_frame_rate", + selectStreams: "v:0" + ).trimmingCharacters(in: .whitespacesAndNewlines).split(separator: ",").map(String.init) + XCTAssertEqual(video.count, 5) + XCTAssertEqual(video[0], "1280") + XCTAssertEqual(video[1], "720") + XCTAssertEqual(video[2], "1:1") + XCTAssertEqual(video[3], "16:9") + XCTAssertEqual(video[4], "30000/1001") + + let audioCodec = try probeValue(output, ffprobe: ffprobe, entries: "stream=codec_name", selectStreams: "a:0") + .trimmingCharacters(in: .whitespacesAndNewlines) + XCTAssertEqual(audioCodec, "aac") + let duration = try XCTUnwrap(Double(probeValue(output, ffprobe: ffprobe, entries: "format=duration").trimmingCharacters(in: .whitespacesAndNewlines))) + XCTAssertGreaterThan(duration, 1.8) + XCTAssertLessThan(duration, 2.2) + XCTAssertEqual(try decodeVideo(output, ffmpeg: ffmpeg), 0, "The converted video and audio must decode cleanly") + } + @MainActor func testUnreadableSourceCanBeRetriedAfterItBecomesAvailable() async throws { let ffmpeg = try availableFFmpeg() @@ -143,11 +348,54 @@ final class ConverterStoreTests: XCTestCase { guard process.terminationStatus == 0 else { throw FixtureError.generationFailed(process.terminationStatus) } } - private func probeValue(_ source: URL, ffprobe: URL, entries: String) throws -> String { + private func availableH264Encoder(ffmpeg: URL) throws -> String? { + let process = Process() + let pipe = Pipe() + process.executableURL = ffmpeg + process.arguments = ["-hide_banner", "-encoders"] + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let output = String(decoding: data, as: UTF8.self) + if output.contains(" libx264 ") { return "libx264" } + if output.contains(" h264_videotoolbox ") { return "h264_videotoolbox" } + return nil + } + + private func make1080pVideo(at output: URL, ffmpeg: URL, encoder: String) throws { + let process = Process() + process.executableURL = ffmpeg + var arguments = [ + "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", "testsrc2=size=1920x1080:rate=30000/1001:duration=2", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2", + "-c:v", encoder + ] + if encoder == "libx264" { + arguments += ["-preset", "ultrafast", "-crf", "18"] + } else { + arguments += ["-b:v", "6M"] + } + arguments += ["-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", "-y", output.path] + process.arguments = arguments + 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, selectStreams: String? = nil) throws -> String { let process = Process() let pipe = Pipe() process.executableURL = ffprobe - process.arguments = ["-v", "error", "-show_entries", entries, "-of", "csv=p=0", source.path] + var arguments = ["-v", "error"] + if let selectStreams { arguments += ["-select_streams", selectStreams] } + arguments += ["-show_entries", entries, "-of", "csv=p=0", source.path] + process.arguments = arguments process.standardOutput = pipe process.standardError = FileHandle.nullDevice try process.run() @@ -157,15 +405,26 @@ final class ConverterStoreTests: XCTestCase { return String(decoding: data, as: UTF8.self) } + private func decodeVideo(_ source: URL, ffmpeg: URL) throws -> Int32 { + let process = Process() + process.executableURL = ffmpeg + process.arguments = ["-hide_banner", "-v", "error", "-i", source.path, "-f", "null", "-"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + return process.terminationStatus + } + @MainActor - private func waitForQueue(_ store: ConverterStore) async throws { - let deadline = Date().addingTimeInterval(20) + private func waitForQueue(_ store: ConverterStore, timeout: TimeInterval = 20) async throws { + let deadline = Date().addingTimeInterval(timeout) 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") + XCTFail("Conversion queue did not complete within \(timeout) seconds") } } } diff --git a/Tests/FastConvertTests/DependencyHealthTests.swift b/Tests/FastConvertTests/DependencyHealthTests.swift new file mode 100644 index 0000000..6304bd9 --- /dev/null +++ b/Tests/FastConvertTests/DependencyHealthTests.swift @@ -0,0 +1,75 @@ +import Foundation +import XCTest +@testable import FastConvert + +final class DependencyHealthTests: XCTestCase { + func testProbeMustBelongToTheFirstSelectedFFmpegInstallation() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let first = try makeBinDirectory("first", in: directory) + let second = try makeBinDirectory("second", in: directory) + let selectedFFmpeg = try makeExecutable("ffmpeg", in: first) + _ = try makeExecutable("ffmpeg", in: second) + _ = try makeExecutable("ffprobe", in: second) + + let tools = DependencyToolResolver.resolve(in: [first, second]) + XCTAssertEqual(tools.ffmpeg, selectedFFmpeg) + XCTAssertNil(tools.ffprobe, "A probe from another installation would falsely report the converter ready") + + let sibling = try makeExecutable("ffprobe", in: first) + XCTAssertEqual(DependencyToolResolver.resolve(in: [first, second]).ffprobe, sibling) + } + + func testAnUnrelatedProbeWithoutFFmpegCannotReportAUsableInstallation() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + _ = try makeExecutable("ffprobe", in: directory) + let tools = DependencyToolResolver.resolve(in: [directory]) + XCTAssertNil(tools.ffmpeg) + XCTAssertNil(tools.ffprobe) + } + + func testNonExecutableSiblingProbeIsRetainedForFailureDiagnostics() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + _ = try makeExecutable("ffmpeg", in: directory) + let probe = directory.appendingPathComponent("ffprobe") + try Data().write(to: probe) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: probe.path) + XCTAssertEqual(DependencyToolResolver.resolve(in: [directory]).ffprobe, probe) + XCTAssertFalse(FileManager.default.isExecutableFile(atPath: probe.path)) + } + + func testExecutableInstallationTakesPriorityOverAnEarlierBrokenCandidate() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let first = try makeBinDirectory("broken", in: directory) + let second = try makeBinDirectory("working", in: directory) + let broken = first.appendingPathComponent("ffmpeg") + try Data().write(to: broken) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: broken.path) + XCTAssertEqual(DependencyToolResolver.resolve(in: [first]).ffmpeg, broken) + let working = try makeExecutable("ffmpeg", in: second) + XCTAssertEqual(DependencyToolResolver.resolve(in: [first, second]).ffmpeg, working) + } + + private func temporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("MP4Flow-ToolResolution-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func makeBinDirectory(_ name: String, in parent: URL) throws -> URL { + let directory = parent.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + @discardableResult + private func makeExecutable(_ name: String, in directory: URL) throws -> URL { + let file = directory.appendingPathComponent(name) + try "#!/bin/sh\nexit 0\n".write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: file.path) + return file + } +} diff --git a/Tests/FastConvertTests/EditorPreviewTests.swift b/Tests/FastConvertTests/EditorPreviewTests.swift new file mode 100644 index 0000000..5f073cf --- /dev/null +++ b/Tests/FastConvertTests/EditorPreviewTests.swift @@ -0,0 +1,114 @@ +import Darwin +import Foundation +import XCTest +@testable import FastConvert + +final class EditorPreviewTests: XCTestCase { + func testEditorProbeReadsDurationDimensionsAndFractionalFrameRate() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let response = directory.appendingPathComponent("synthetic probe response.json") + let payload: [String: Any] = [ + "streams": [ + ["codec_type": "audio"], + ["codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "30000/1001"] + ], + "format": ["duration": "12.345"] + ] + let data = try JSONSerialization.data(withJSONObject: payload) + try data.write(to: response) + let probe = try makeJSONProbe(in: directory) + + let result = await EditorMediaProbe.inspect(source: response, using: probe) + let media = try XCTUnwrap(result) + XCTAssertEqual(media.duration, 12.345, accuracy: 0.0001) + XCTAssertEqual(media.width, 1920) + XCTAssertEqual(media.height, 1080) + XCTAssertEqual(media.frameRate, 30000.0 / 1001.0, accuracy: 0.000001) + XCTAssertEqual(media.frameDuration, 1001.0 / 30000.0, accuracy: 0.000001) + } + + func testEditorProbeRejectsMalformedAndUnusableMediaResponses() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("damaged input.json") + let probe = try makeJSONProbe(in: directory) + let invalidResponses = [ + "{truncated media response", + #"{"streams":[],"format":{"duration":"2"}}"#, + #"{"streams":[{"codec_type":"video","width":0,"height":1080}],"format":{"duration":"2"}}"#, + #"{"streams":[{"codec_type":"video","width":1920,"height":1080}],"format":{"duration":"nan"}}"# + ] + for response in invalidResponses { + try response.write(to: source, atomically: true, encoding: .utf8) + let media = await EditorMediaProbe.inspect(source: source, using: probe) + XCTAssertNil(media, "Malformed or unusable probe data must not create an editor timeline") + } + } + + func testClosingEditorCancelsSilentProbeAndReapsItsProcess() async throws { + guard FileManager.default.isExecutableFile(atPath: "/usr/bin/perl") else { + throw XCTSkip("System Perl is required for the single-process cancellation fixture") + } + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let pidFile = directory.appendingPathComponent("owned probe.pid") + let probe = directory.appendingPathComponent("silent ffprobe") + // A direct shebang interpreter is the owned process, with no shell or + // descendants holding its pipes. Ignore TERM to exercise bounded KILL. + let fixture = """ + #!/usr/bin/perl + $SIG{TERM} = 'IGNORE'; + open(my $pid, '>', $ARGV[-1]) or die "cannot create pid marker"; + print $pid "$$"; + close($pid); + while (1) { select(undef, undef, undef, 1); } + """ + try fixture.write(to: probe, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: probe.path) + + let inspection = Task { await EditorMediaProbe.inspect(source: pidFile, using: probe) } + let launchDeadline = Date().addingTimeInterval(2) + var processID: Int32? + while Date() < launchDeadline { + if let text = try? String(contentsOf: pidFile, encoding: .utf8), let pid = Int32(text), pid > 1 { + processID = pid + break + } + try await Task.sleep(nanoseconds: 10_000_000) + } + guard let processID else { + inspection.cancel() + _ = await inspection.value + return XCTFail("The silent probe must actually launch before testing cancellation") + } + defer { + if Darwin.kill(processID, 0) == 0 { _ = Darwin.kill(processID, SIGKILL) } + } + XCTAssertEqual(Darwin.kill(processID, 0), 0, "The probe must still be running when the editor closes") + let started = ProcessInfo.processInfo.systemUptime + inspection.cancel() + let media = await inspection.value + + XCTAssertNil(media) + XCTAssertLessThan(ProcessInfo.processInfo.systemUptime - started, 2, "Cancellation must not wait for the five-second editor-probe timeout") + errno = 0 + let processStatus = Darwin.kill(processID, 0) + let processError = errno + XCTAssertEqual(processStatus, -1, "A cancelled editor must not leave its probe alive") + XCTAssertEqual(processError, ESRCH, "The owned child should have exited and been reaped before inspection returns") + } + + private func temporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("MP4Flow-EditorPreview-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func makeJSONProbe(in directory: URL) throws -> URL { + let executable = directory.appendingPathComponent("JSON ffprobe") + try "#!/bin/sh\nfor source in \"$@\"; do :; done\nexec /bin/cat \"$source\"\n".write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + return executable + } +} diff --git a/Tests/FastConvertTests/GeometryRegressionTests.swift b/Tests/FastConvertTests/GeometryRegressionTests.swift new file mode 100644 index 0000000..51dfbe7 --- /dev/null +++ b/Tests/FastConvertTests/GeometryRegressionTests.swift @@ -0,0 +1,126 @@ +import Foundation +import XCTest +@testable import FastConvert + +final class GeometryRegressionTests: XCTestCase { + @MainActor + func testSoftwareRotationAndResizePreserveAnamorphicDisplayRatio() async throws { + try await checkAnamorphicRotation(preset: .h264Software) + } + + @MainActor + func testHardwareRotationAndResizePreserveAnamorphicDisplayRatio() async throws { + let ffmpeg = try mediaTools().ffmpeg + guard try run(ffmpeg, [ + "-hide_banner", "-loglevel", "error", "-nostdin", + "-f", "lavfi", "-i", "color=size=320x240:rate=12:duration=0.1", + "-c:v", "h264_videotoolbox", "-allow_sw", "0", "-frames:v", "1", "-f", "null", "-" + ]).status == 0 else { + throw XCTSkip("A working VideoToolbox H.264 hardware encoder is required") + } + try await checkAnamorphicRotation(preset: .h264Hardware) + } + + @MainActor + private func checkAnamorphicRotation(preset: ConversionPreset) async throws { + let tools = try mediaTools() + let encoders = try run(tools.ffmpeg, ["-hide_banner", "-encoders"]) + guard encoders.status == 0, String(decoding: encoders.output, as: UTF8.self).contains(" libx264 ") else { + throw XCTSkip("FFmpeg libx264 is required to generate the anamorphic fixture") + } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("MP4Flow-GeometryTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let source = directory.appendingPathComponent("anamorphic.mp4") + let fixture = try run(tools.ffmpeg, [ + "-hide_banner", "-loglevel", "error", "-nostdin", + "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=12:duration=1.2", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=1.2", + "-vf", "setsar=4/3", "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-shortest", "-y", source.path + ]) + guard fixture.status == 0 else { return XCTFail("Generating the anamorphic fixture failed with code \(fixture.status)") } + let original = try probe(source, using: tools.ffprobe) + let sourceVideo = try XCTUnwrap(original.streams.first { $0["codec_type"] as? String == "video" }) + XCTAssertEqual(sourceVideo["sample_aspect_ratio"] as? String, "4:3") + XCTAssertEqual(sourceVideo["display_aspect_ratio"] as? String, "16:9") + + for resolution: OutputResolution in [.original, .p480] { + let store = ConverterStore() + store.preset = preset + store.outputResolution = resolution + store.outputDirectory = directory + let item = ConversionItem(source: source) + store.items = [item] + store.scheduleRotation(item, rotation: .clockwise) + store.start() + let deadline = Date().addingTimeInterval(20) + while store.isRunning && Date() < deadline { + try await Task.sleep(nanoseconds: 50_000_000) + } + if store.isRunning { + store.cancel() + return XCTFail("The anamorphic conversion did not complete within 20 seconds") + } + guard case .complete(let output) = store.items[0].state else { + return XCTFail("Expected \(preset.rawValue)/\(resolution.rawValue) to complete; got \(store.items[0].state)") + } + + let result = try probe(output, using: tools.ffprobe) + let video = try XCTUnwrap(result.streams.first { $0["codec_type"] as? String == "video" }) + let audio = try XCTUnwrap(result.streams.first { $0["codec_type"] as? String == "audio" }) + let width = try XCTUnwrap(video["width"] as? Int) + let height = try XCTUnwrap(video["height"] as? Int) + let sar = try XCTUnwrap(ratio(video["sample_aspect_ratio"] as? String)) + let displayRatio = Double(width) / Double(height) * sar + XCTAssertEqual(displayRatio, 9.0 / 16.0, accuracy: 0.001, "A quarter turn must invert the displayed 16:9 ratio") + XCTAssertEqual(try XCTUnwrap(ratio(video["display_aspect_ratio"] as? String)), 9.0 / 16.0, accuracy: 0.001) + XCTAssertEqual(height, resolution == .original ? 320 : 480) + XCTAssertEqual(audio["codec_name"] as? String, "aac") + XCTAssertEqual(result.duration, original.duration, accuracy: 0.15) + XCTAssertEqual(try XCTUnwrap(Double(audio["duration"] as? String ?? "")), result.duration, accuracy: 0.15) + XCTAssertEqual(try run(tools.ffmpeg, ["-v", "error", "-nostdin", "-xerror", "-i", output.path, "-map", "0:v:0", "-map", "0:a:0", "-f", "null", "-"]).status, 0) + } + } + + private func mediaTools() throws -> (ffmpeg: URL, ffprobe: URL) { + for root in ["/opt/homebrew/bin", "/usr/local/bin"] { + let ffmpeg = URL(fileURLWithPath: root).appendingPathComponent("ffmpeg") + let ffprobe = URL(fileURLWithPath: root).appendingPathComponent("ffprobe") + if FileManager.default.isExecutableFile(atPath: ffmpeg.path), FileManager.default.isExecutableFile(atPath: ffprobe.path) { + return (ffmpeg, ffprobe) + } + } + throw XCTSkip("FFmpeg and ffprobe are required") + } + + private func probe(_ source: URL, using ffprobe: URL) throws -> (streams: [[String: Any]], duration: Double) { + let result = try run(ffprobe, ["-v", "error", "-show_streams", "-show_format", "-of", "json", source.path]) + XCTAssertEqual(result.status, 0) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: result.output) as? [String: Any]) + let streams = try XCTUnwrap(object["streams"] as? [[String: Any]]) + let format = try XCTUnwrap(object["format"] as? [String: Any]) + return (streams, try XCTUnwrap(Double(format["duration"] as? String ?? ""))) + } + + private func ratio(_ value: String?) -> Double? { + guard let value else { return nil } + let parts = value.split(separator: ":") + guard parts.count == 2, let numerator = Double(parts[0]), let denominator = Double(parts[1]), denominator != 0 else { return nil } + return numerator / denominator + } + + private func run(_ executable: URL, _ arguments: [String]) throws -> (status: Int32, output: Data) { + let process = Process() + let pipe = Pipe() + process.executableURL = executable + process.arguments = arguments + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + try process.run() + let output = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return (process.terminationStatus, output) + } +} diff --git a/Tests/FastConvertTests/MediaNoticeTests.swift b/Tests/FastConvertTests/MediaNoticeTests.swift new file mode 100644 index 0000000..1b323ec --- /dev/null +++ b/Tests/FastConvertTests/MediaNoticeTests.swift @@ -0,0 +1,135 @@ +import Foundation +import XCTest +@testable import FastConvert + +final class MediaNoticeTests: XCTestCase { + func testOrdinaryFilesAndVideoOnlyFilesHaveNoNotice() { + for preset in [ConversionPreset.remux, .h264Hardware, .h264Software, .smartEnhanceUpscale] { + XCTAssertNil(ConverterStore.mediaHandlingNotice(for: fixture(), preset: preset)) + XCTAssertNil(ConverterStore.mediaHandlingNotice(for: fixture(audioTracks: 0), preset: preset), "Video-only input is a supported format, not a warning") + } + } + + func testNoticesDescribeOnlyRealContentLoss() throws { + let notice = try XCTUnwrap(ConverterStore.mediaHandlingNotice(for: fixture(videoTracks: 2, audioTracks: 3, subtitles: 2, chapters: 4), preset: .remux)) + XCTAssertEqual(notice.issues, [.extraVideo(2), .extraAudio(3), .subtitles(2), .chapters(4)]) + XCTAssertTrue(notice.summary.isEmpty) + XCTAssertEqual(notice.warnings, [ + L10n.format("检测到 %d 路视频;仅保留第一路。", 2), + L10n.format("检测到 %d 路音频;仅保留第一路。", 3), + L10n.format("检测到 %d 条字幕;输出不保留字幕。", 2), + L10n.format("检测到 %d 个章节;输出不保留章节。", 4) + ]) + XCTAssertFalse(notice.detail.hasPrefix("\n")) + XCTAssertFalse(notice.detail.contains(L10n.text("容器元数据和章节将被移除。")), "Routine metadata cleanup belongs in the policy detail, not every queue row") + } + + func testHDRWarnsForReencodingButNotForUntouchedVideoCopy() throws { + let hdr = fixture(colorTransfer: "smpte2084") + XCTAssertNil(ConverterStore.mediaHandlingNotice(for: hdr, preset: .remux)) + let notice = try XCTUnwrap(ConverterStore.mediaHandlingNotice(for: hdr, preset: .h264Hardware)) + XCTAssertEqual(notice.warnings, [L10n.format("HDR 将重新编码,色彩或动态范围可能改变;如需保留,请保持原尺寸并使用「%@」。", ConversionPreset.remux.title)]) + } + + func testNormalRotationIsQuietAndNonstandardReencodingIsExplained() throws { + XCTAssertNil(ConverterStore.mediaHandlingNotice(for: fixture(rotation: 90), preset: .h264Hardware)) + XCTAssertNil(ConverterStore.mediaHandlingNotice(for: fixture(rotation: 45), preset: .remux)) + let notice = try XCTUnwrap(ConverterStore.mediaHandlingNotice(for: fixture(rotation: 45), preset: .h264Hardware)) + XCTAssertEqual(notice.warnings, [L10n.text("非标准旋转角度将重绘画面;请核对输出方向与边缘。")]) + } + + func testUnconfirmedProbeIsNotPresentedAsSafe() throws { + let failed = try XCTUnwrap(ConverterStore.mediaHandlingNotice(for: fixture(probeSucceeded: false), preset: .remux)) + XCTAssertEqual(failed.summary, L10n.text("素材信息尚未确认")) + XCTAssertFalse(failed.warnings.isEmpty) + XCTAssertNotNil(ConverterStore.mediaHandlingNotice(for: fixture(videoTracks: 0), preset: .remux)) + } + + func testMissingSourceDoesNotBecomeAPermissionError() { + let failure = ConverterStore.failure(from: "MP4Flow:missing-source") + XCTAssertEqual(failure.reasonKey, "源文件已移动、删除或无法访问") + XCTAssertEqual(failure.solutionKey, "请在访达中确认源文件仍存在,重新添加后再试。") + XCTAssertEqual(failure.diagnostic, "ffmpeg:missing-source") + XCTAssertEqual(failure.reason, L10n.text("源文件已移动、删除或无法访问")) + XCTAssertEqual(failure.solution, L10n.text("请在访达中确认源文件仍存在,重新添加后再试。")) + XCTAssertNotEqual(failure.reason, ConverterStore.failure(from: "MP4Flow:permission").reason) + } + + func testRetainedFailuresAndNoticesResolveLanguageWhenRead() throws { + let failure = ConverterStore.failure(from: "MP4Flow:missing-source") + let notice = try XCTUnwrap(ConverterStore.mediaHandlingNotice(for: fixture(audioTracks: 2, colorTransfer: "smpte2084"), preset: .h264Hardware)) + let unknown = try XCTUnwrap(ConverterStore.mediaHandlingNotice(for: fixture(probeSucceeded: false), preset: .remux)) + XCTAssertEqual(failure.reasonKey, "源文件已移动、删除或无法访问") + XCTAssertEqual(failure.solutionKey, "请在访达中确认源文件仍存在,重新添加后再试。") + XCTAssertEqual(notice.issues, [.extraAudio(2), .hdrReencoding]) + XCTAssertEqual(unknown.issues, [.unconfirmed]) + + // Override only the process's volatile argument domain. Do not change + // persistent app preferences while exercising an in-memory result. + let defaults = UserDefaults.standard + let domainName = "NSArgumentDomain" + let originalDomain = defaults.volatileDomain(forName: domainName) + defer { defaults.setVolatileDomain(originalDomain, forName: domainName) } + for language in AppLanguage.allCases { + var domain = originalDomain + domain["appLanguage"] = language.rawValue + defaults.setVolatileDomain(domain, forName: domainName) + XCTAssertEqual(failure.reason, L10n.text(failure.reasonKey)) + XCTAssertEqual(failure.solution, L10n.text(failure.solutionKey)) + XCTAssertEqual(failure, ConverterStore.failure(from: "MP4Flow:missing-source"), "Storage must remain language-independent") + XCTAssertEqual(notice.warnings, [ + L10n.format("检测到 %d 路音频;仅保留第一路。", 2), + L10n.format("HDR 将重新编码,色彩或动态范围可能改变;如需保留,请保持原尺寸并使用「%@」。", ConversionPreset.remux.title) + ]) + XCTAssertEqual(unknown.summary, L10n.text("素材信息尚未确认")) + XCTAssertEqual(unknown.warnings, [L10n.text("请确认文件可播放后重试;检测完成前不会开始转换。")]) + } + } + + func testProbeCountsVideoTracksAndActualChapters() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("MP4Flow-notices-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("probe.json") + let json = """ + {"streams":[ + {"codec_type":"video","codec_name":"h264","width":1280,"height":720,"sample_aspect_ratio":"1:1"}, + {"codec_type":"video","codec_name":"h264","width":1280,"height":720}, + {"codec_type":"audio","codec_name":"aac"} + ],"format":{"duration":"1.0"},"chapters":[{"id":0},{"id":1}]} + """ + try json.write(to: source, atomically: true, encoding: .utf8) + let probe = directory.appendingPathComponent("ffprobe") + try "#!/bin/sh\nfor argument in \"$@\"; do :; done\nexec /bin/cat \"$argument\"\n".write(to: probe, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: probe.path) + let info = await ConverterStore.inspectMedia(at: source, using: probe) + XCTAssertTrue(info.probeSucceeded) + XCTAssertEqual(info.videoTrackCount, 2) + XCTAssertEqual(info.chapterCount, 2) + let notice = try XCTUnwrap(ConverterStore.mediaHandlingNotice(for: info, preset: .remux)) + XCTAssertEqual(notice.warnings.count, 2) + } + +#if compiler(>=6.4) + func testEnhancementErrorsKeepTheirCauseWithoutExposingPrivateDetails() throws { + guard #available(macOS 27.0, *) else { throw XCTSkip("The native enhancement error type requires macOS 27") } + let writer = ConverterStore.enhancementFailure(for: .writer("Private path /private/customer/video.mp4: failed")) + XCTAssertEqual(writer.diagnostic, "enhance:write-failed") + XCTAssertEqual(writer.reason, L10n.text("无法写入智能增强输出文件。")) + XCTAssertEqual(writer.solution, L10n.text("请检查剩余空间和输出文件夹权限,或改用本地文件夹后重试。")) + XCTAssertFalse([writer.reason, writer.solution, writer.diagnostic ?? ""].joined().contains("customer")) + XCTAssertEqual(ConverterStore.enhancementFailure(for: .reader("private details")).diagnostic, "enhance:unreadable-source") + XCTAssertEqual(ConverterStore.enhancementFailure(for: .unavailable).diagnostic, "enhance:unavailable") + XCTAssertEqual(ConverterStore.enhancementFailure(for: .processingTimeout).diagnostic, "enhance:processing-timeout") + XCTAssertEqual(ConverterStore.enhancementFailure(for: .finalizationTimeout).diagnostic, "enhance:finalization-timeout") + } +#endif + + private func fixture(videoTracks: Int = 1, audioTracks: Int = 1, subtitles: Int = 0, chapters: Int = 0, colorTransfer: String? = nil, rotation: Double? = nil, probeSucceeded: Bool = true) -> VideoInfo { + var info = VideoInfo(duration: 1, videoRate: 1_000_000, audioRate: audioTracks > 0 ? 128_000 : nil, width: 1280, height: 720, hasVideo: videoTracks > 0, videoCodec: "h264", audioCodec: audioTracks > 0 ? "aac" : nil, frameRate: "30/1", pixelFormat: "yuv420p", audioSampleRate: "48000", audioChannels: 2, audioTrackCount: audioTracks, subtitleTrackCount: subtitles, colorTransfer: colorTransfer, probeSucceeded: probeSucceeded) + info.videoTrackCount = videoTracks + info.chapterCount = chapters + info.displayRotation = rotation + return info + } +} diff --git a/Tests/FastConvertTests/MergeGeometryTests.swift b/Tests/FastConvertTests/MergeGeometryTests.swift new file mode 100644 index 0000000..0792ed5 --- /dev/null +++ b/Tests/FastConvertTests/MergeGeometryTests.swift @@ -0,0 +1,138 @@ +import Foundation +import XCTest +@testable import FastConvert + +final class MergeGeometryTests: XCTestCase { + @MainActor + func testCompatibleMergeNormalizesAnamorphicPixelsWithoutStretchingContent() async throws { + try await checkAnamorphicMerge(rotateFirst: false) + } + + @MainActor + func testCompatibleMergeUsesRotatedDisplayCanvasWithoutStretchingContent() async throws { + try await checkAnamorphicMerge(rotateFirst: true) + } + + @MainActor + private func checkAnamorphicMerge(rotateFirst: Bool) async throws { + let tools = try mediaTools() + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let first = directory.appendingPathComponent("anamorphic.mp4") + let second = directory.appendingPathComponent("square.mp4") + try await makeVideo(first, sar: "4/3", ffmpeg: tools.ffmpeg) + try await makeVideo(second, sar: "1", ffmpeg: tools.ffmpeg) + let displayedFirst: URL + if rotateFirst { + displayedFirst = directory.appendingPathComponent("rotated-anamorphic.mp4") + let rotation = await FFmpegProcessRunner(executable: tools.ffmpeg, arguments: [ + "-v", "error", "-nostdin", "-display_rotation:v:0", "90", "-i", first.path, "-c", "copy", "-y", displayedFirst.path + ]).run() + XCTAssertTrue(rotation.succeeded) + } else { + displayedFirst = first + } + + let store = ConverterStore() + store.outputDirectory = directory + store.items = [ConversionItem(source: displayedFirst), ConversionItem(source: second)] + store.mergeWaitingItems() + try await waitForMerge(store) + XCTAssertTrue(store.showCompatibleMergePrompt, "Different pixel aspect ratios must not be stream-concatenated") + XCTAssertNil(store.mergeOutput) + guard store.showCompatibleMergePrompt else { return } + + store.startCompatibleMerge() + try await waitForMerge(store) + let output = try XCTUnwrap(store.mergeOutput, store.mergeNotice) + let info = await ConverterStore.inspectMedia(at: output, using: tools.ffprobe) + XCTAssertTrue(info.hasVideo) + XCTAssertEqual(info.duration ?? 0, 2, accuracy: 0.15) + XCTAssertEqual(info.displayGeometry?.hasSquarePixels, true) + let width = try XCTUnwrap(info.width) + let height = try XCTUnwrap(info.height) + XCTAssertEqual(Double(width) / Double(height), rotateFirst ? 9.0 / 16 : 16.0 / 9, accuracy: 0.01) + let frame = await FFmpegProcessRunner(executable: tools.ffmpeg, arguments: [ + "-hide_banner", "-loglevel", "error", "-nostdin", "-i", output.path, + "-frames:v", "1", "-vf", "format=gray", "-f", "rawvideo", "-" + ]).run() + XCTAssertTrue(frame.succeeded) + XCTAssertEqual(frame.standardOutput.count, width * height) + guard frame.standardOutput.count == width * height else { return } + let pixels = [UInt8](frame.standardOutput) + var minX = width, maxX = -1, minY = height, maxY = -1 + for y in 0.. 180 { + minX = min(minX, x); maxX = max(maxX, x) + minY = min(minY, y); maxY = max(maxY, y) + } + } + XCTAssertGreaterThan(maxX, minX) + XCTAssertGreaterThan(maxY, minY) + XCTAssertEqual(Double(maxX - minX + 1) / Double(maxY - minY + 1), 1, accuracy: 0.04, + "The 60×80 encoded marker is a displayed square at SAR 4:3 and must remain square") + let decode = await FFmpegProcessRunner(executable: tools.ffmpeg, arguments: ["-v", "error", "-nostdin", "-xerror", "-i", output.path, "-f", "null", "-"]).run() + XCTAssertTrue(decode.succeeded) + } + + @MainActor + func testLosslessMergeRejectsDifferentVideoTimeBasesAndDisplayRotations() async throws { + let tools = try mediaTools() + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let first = directory.appendingPathComponent("baseline.mp4") + let clock = directory.appendingPathComponent("different-clock.mp4") + let rotated = directory.appendingPathComponent("rotated.mp4") + try await makeVideo(first, sar: "1", ffmpeg: tools.ffmpeg) + let retimed = await FFmpegProcessRunner(executable: tools.ffmpeg, arguments: [ + "-v", "error", "-nostdin", "-i", first.path, "-c", "copy", "-video_track_timescale", "90000", "-y", clock.path + ]).run() + XCTAssertTrue(retimed.succeeded) + let rotation = await FFmpegProcessRunner(executable: tools.ffmpeg, arguments: [ + "-v", "error", "-nostdin", "-display_rotation:v:0", "90", "-i", first.path, "-c", "copy", "-y", rotated.path + ]).run() + XCTAssertTrue(rotation.succeeded) + for second in [clock, rotated] { + let store = ConverterStore() + store.outputDirectory = directory + store.items = [ConversionItem(source: first), ConversionItem(source: second)] + store.mergeWaitingItems() + try await waitForMerge(store) + XCTAssertTrue(store.showCompatibleMergePrompt, "Different stream clocks or display orientation require normalized merging") + XCTAssertNil(store.mergeOutput) + } + } + + private func mediaTools() throws -> (ffmpeg: URL, ffprobe: URL) { + for root in ["/opt/homebrew/bin", "/usr/local/bin"] { + let ffmpeg = URL(fileURLWithPath: root).appendingPathComponent("ffmpeg") + let ffprobe = URL(fileURLWithPath: root).appendingPathComponent("ffprobe") + if FileManager.default.isExecutableFile(atPath: ffmpeg.path), FileManager.default.isExecutableFile(atPath: ffprobe.path) { return (ffmpeg, ffprobe) } + } + throw XCTSkip("FFmpeg and ffprobe are required") + } + + private func makeDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("MP4Flow-MergeGeometry-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func makeVideo(_ output: URL, sar: String, ffmpeg: URL) async throws { + let encoders = await FFmpegProcessRunner(executable: ffmpeg, arguments: ["-hide_banner", "-encoders"]).run() + guard encoders.succeeded, String(decoding: encoders.standardOutput, as: UTF8.self).contains(" libx264 ") else { throw XCTSkip("libx264 is required") } + let result = await FFmpegProcessRunner(executable: ffmpeg, arguments: [ + "-v", "error", "-nostdin", "-f", "lavfi", "-i", "color=black:size=320x240:rate=12:duration=1", + "-vf", "drawbox=x=100:y=80:w=60:h=80:color=white:t=fill,setsar=\(sar)", + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-y", output.path + ]).run() + XCTAssertTrue(result.succeeded, "Synthetic merge fixture generation must succeed") + } + + @MainActor + private func waitForMerge(_ store: ConverterStore) async throws { + let deadline = Date().addingTimeInterval(20) + while store.isMerging && Date() < deadline { try await Task.sleep(nanoseconds: 20_000_000) } + if store.isMerging { store.cancel(); XCTFail("Merge did not finish within the test deadline") } + } +} diff --git a/Tests/FastConvertTests/ProcessLifecycleTests.swift b/Tests/FastConvertTests/ProcessLifecycleTests.swift new file mode 100644 index 0000000..af7e6db --- /dev/null +++ b/Tests/FastConvertTests/ProcessLifecycleTests.swift @@ -0,0 +1,216 @@ +import Foundation +import XCTest +@testable import FastConvert + +final class ProcessLifecycleTests: XCTestCase { + func testLargeStdoutAndStderrDrainThroughEOF() async { + let script = """ + i=0 + while [ "$i" -lt 4000 ]; do + printf 'output-packet-0123456789012345678901234567890123456789\n' + printf 'error-packet-01234567890123456789012345678901234567890\n' >&2 + i=$((i + 1)) + done + printf 'final-output' + printf 'final-error' >&2 + exit 7 + """ + let result = await shell(script).run() + XCTAssertEqual(result.terminationStatus, 7) + XCTAssertGreaterThan(result.standardOutput.count, 64 * 1_024) + XCTAssertTrue(String(decoding: result.standardOutput, as: UTF8.self).hasSuffix("final-output")) + XCTAssertTrue(result.standardError.hasSuffix("final-error")) + XCTAssertLessThanOrEqual(result.standardError.utf8.count, 4_000) + XCTAssertFalse(result.timedOut) + XCTAssertFalse(result.wasCancelled) + } + + func testStderrNoiseCannotKeepStalledProcessAlive() async { + let runner = shell( + "trap '' TERM; while :; do printf 'decoder still waiting\\n' >&2; done", + timeout: 0.2 + ) + let started = Date() + let result = await runner.run() + XCTAssertTrue(result.timedOut) + XCTAssertFalse(result.wasCancelled) + XCTAssertFalse(result.succeeded) + XCTAssertFalse(result.standardError.isEmpty) + XCTAssertLessThan(Date().timeIntervalSince(started), 3) + } + + func testCancellationEscalatesForUnresponsiveProcess() async throws { + let runner = shell("trap '' TERM; while :; do :; done") + let task = Task { await runner.run() } + try await Task.sleep(nanoseconds: 100_000_000) + let started = Date() + runner.cancel() + let result = await task.value + XCTAssertTrue(result.wasCancelled) + XCTAssertFalse(result.timedOut) + XCTAssertFalse(result.succeeded) + XCTAssertLessThan(Date().timeIntervalSince(started), 3) + } + + func testRepeatedUnchangedProgressDoesNotHideStall() async { + let runner = FFmpegProcessRunner( + executable: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", "trap '' TERM; while :; do printf 'frame=1\\nout_time_us=1000\\ntotal_size=100\\nprogress=continue\\n'; done"], + configuration: FFmpegProcessRunner.Configuration( + inactivityTimeout: 0.2, + terminationGrace: 0.05, + pollingInterval: 0.02, + activityPolicy: .advancingFFmpegProgress, + maximumOutputBytes: 0 + ) + ) + let started = Date() + let result = await runner.run() + XCTAssertTrue(result.timedOut) + XCTAssertTrue(result.standardOutput.isEmpty) + XCTAssertLessThan(Date().timeIntervalSince(started), 3) + } + + func testCancellationBeforeLaunchPreventsExecution() async { + let runner = shell("printf 'must not execute'") + runner.cancel() + let result = await runner.run() + XCTAssertTrue(result.wasCancelled) + XCTAssertNil(result.terminationStatus) + XCTAssertTrue(result.standardOutput.isEmpty) + } + + func testCancellingInspectionTaskStopsItsOwnedProcess() async throws { + let runner = shell("trap '' TERM; while :; do :; done") + let task = Task { + await ConverterStore.inspectMedia( + at: URL(fileURLWithPath: "/unused-media"), + using: URL(fileURLWithPath: "/bin/sh"), + runner: runner + ) + } + try await Task.sleep(nanoseconds: 100_000_000) + let started = Date() + task.cancel() + let result = await task.value + XCTAssertFalse(result.probeSucceeded) + XCTAssertLessThan(Date().timeIntervalSince(started), 3) + } + + func testLaunchFailureCompletesPipeDrains() async { + let runner = FFmpegProcessRunner(executable: URL(fileURLWithPath: "/MP4Flow-nonexistent-executable"), arguments: []) + let started = Date() + let result = await runner.run() + XCTAssertNotNil(result.launchError) + XCTAssertNil(result.terminationStatus) + XCTAssertLessThan(Date().timeIntervalSince(started), 3) + } + + func testCompletedRunnerReleasesWatchdogPromptly() async throws { + var runner: FFmpegProcessRunner? = shell("printf 'complete'", timeout: 60) + let reference = WeakProcessRunnerReference(runner) + let result = await runner!.run() + XCTAssertTrue(result.succeeded) + runner = nil + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertNil(reference.value, "A completed subprocess must not stay retained until its old watchdog deadline") + } + + @MainActor + func testCancelledMergeCanRestartAndPublishVerifiedOutput() async throws { + guard let ffmpeg = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/local/bin/ffmpeg"] + .map({ URL(fileURLWithPath: $0) }) + .first(where: { FileManager.default.isExecutableFile(atPath: $0.path) + && FileManager.default.isExecutableFile(atPath: $0.deletingLastPathComponent().appendingPathComponent("ffprobe").path) }) else { + throw XCTSkip("FFmpeg and ffprobe are required for the merge integration check") + } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("MP4Flow-MergeLifecycle-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let first = directory.appendingPathComponent("first.mp4") + let second = directory.appendingPathComponent("second.mp4") + let generation = await FFmpegProcessRunner( + executable: ffmpeg, + arguments: ["-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=red:s=64x48:r=8:d=2", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", first.path] + ).run() + guard generation.succeeded else { throw XCTSkip("libx264 is required to create the merge fixture") } + try FileManager.default.copyItem(at: first, to: second) + + let controlledFFmpeg = directory.appendingPathComponent("ffmpeg") + let quotedFFmpeg = "'" + ffmpeg.path.replacingOccurrences(of: "'", with: "'\\''") + "'" + // exec replaces the wrapper process so cancellation targets FFmpeg + // itself and no shell child can keep its inherited pipes open. + try "#!/bin/sh\nexec \(quotedFFmpeg) -re \"$@\"\n".write(to: controlledFFmpeg, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: controlledFFmpeg.path) + try FileManager.default.createSymbolicLink( + at: directory.appendingPathComponent("ffprobe"), + withDestinationURL: ffmpeg.deletingLastPathComponent().appendingPathComponent("ffprobe") + ) + + let store = ConverterStore(ffmpegOverride: controlledFFmpeg) + store.outputDirectory = directory + store.items = [ConversionItem(source: first), ConversionItem(source: second)] + store.mergeWaitingItems() + XCTAssertTrue(store.isMerging) + let runningDeadline = Date().addingTimeInterval(5) + var runningPartial: URL? + while store.isMerging && Date() < runningDeadline { + let partial = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.fileSizeKey]) + .first { $0.lastPathComponent.hasPrefix(".MP4Flow-") && $0.lastPathComponent.hasSuffix(".partial.mp4") } + if let partial, (try partial.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0) > 0, + (store.mergeProgress ?? 0) > 0 { + runningPartial = partial + break + } + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTAssertNotNil(runningPartial, "Cancel must exercise a running writer, not merely the initial probe") + store.cancel() + try await waitForMerge(store) + XCTAssertNil(store.mergeOutput) + if let runningPartial { XCTAssertFalse(FileManager.default.fileExists(atPath: runningPartial.path)) } + let afterCancel = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "mp4" } + XCTAssertEqual(Set(afterCancel.map(\.lastPathComponent)), Set([first.lastPathComponent, second.lastPathComponent]), "A cancelled merge must not publish its partial output") + + store.mergeWaitingItems() + try await waitForMerge(store) + let output = try XCTUnwrap(store.mergeOutput, store.mergeNotice) + let info = await ConverterStore.inspectMedia(at: output, using: ffmpeg.deletingLastPathComponent().appendingPathComponent("ffprobe")) + XCTAssertTrue(info.probeSucceeded) + XCTAssertTrue(info.hasVideo) + XCTAssertEqual(info.duration ?? 0, 4, accuracy: 0.15) + let leftovers = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.lastPathComponent.hasPrefix(".MP4Flow-") } + XCTAssertTrue(leftovers.isEmpty) + } + + private func shell(_ script: String, timeout: TimeInterval = 5) -> FFmpegProcessRunner { + FFmpegProcessRunner( + executable: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", script], + configuration: FFmpegProcessRunner.Configuration( + inactivityTimeout: timeout, + terminationGrace: 0.05, + pollingInterval: 0.02 + ) + ) + } + + @MainActor + private func waitForMerge(_ store: ConverterStore) async throws { + let deadline = Date().addingTimeInterval(10) + while store.isMerging && Date() < deadline { + try await Task.sleep(nanoseconds: 20_000_000) + } + if store.isMerging { + store.cancel() + XCTFail("The merge did not finish within its test deadline") + } + } +} + +private final class WeakProcessRunnerReference { + weak var value: FFmpegProcessRunner? + init(_ value: FFmpegProcessRunner?) { self.value = value } +} diff --git a/Tests/FastConvertTests/RealtimeProgressTests.swift b/Tests/FastConvertTests/RealtimeProgressTests.swift new file mode 100644 index 0000000..de00fa3 --- /dev/null +++ b/Tests/FastConvertTests/RealtimeProgressTests.swift @@ -0,0 +1,182 @@ +import Foundation +import XCTest +@testable import FastConvert + +final class RealtimeProgressTests: XCTestCase { + func testSmallProgressPacketsArriveWhileChildIsStillRunning() async throws { + let perl = try systemPerl() + let observed = RealtimeProgressObservations() + let started = ProcessInfo.processInfo.systemUptime + let result = await FFmpegProcessRunner( + executable: perl, + arguments: ["-e", periodicProgressScript(packetCount: 8, interval: 0.18)], + configuration: FFmpegProcessRunner.Configuration(inactivityTimeout: 5) + ).run { data in + observed.record(data, elapsed: ProcessInfo.processInfo.systemUptime - started) + } + let elapsed = ProcessInfo.processInfo.systemUptime - started + let snapshot = observed.snapshot() + if let first = snapshot.firstCallback { + print(String(format: "Live pipe first packet: %.3f s; callbacks: %d", first, snapshot.callbackCount)) + } + + XCTAssertTrue(result.succeeded, result.standardError) + XCTAssertGreaterThanOrEqual(elapsed, 1.2, "The producer must stay alive long enough to distinguish streaming from EOF-only reads") + XCTAssertLessThan(try XCTUnwrap(snapshot.firstCallback), 0.5, "A small progress packet must not wait for a 64 KB buffer or process EOF") + XCTAssertGreaterThanOrEqual(snapshot.callbackCount, 3, "Periodic small writes should produce multiple live callbacks") + XCTAssertTrue(snapshot.text.contains("progress=end"), "The final packet must still be drained") + } + + func testAdvancingSmallPacketsPreventFalseInactivityTimeout() async throws { + let perl = try systemPerl() + let observed = RealtimeProgressObservations() + let started = ProcessInfo.processInfo.systemUptime + let result = await FFmpegProcessRunner( + executable: perl, + arguments: ["-e", periodicProgressScript(packetCount: 10, interval: 0.15)], + configuration: FFmpegProcessRunner.Configuration( + inactivityTimeout: 0.6, + terminationGrace: 0.05, + pollingInterval: 0.02, + activityPolicy: .advancingFFmpegProgress, + maximumOutputBytes: 0 + ) + ).run { data in + observed.record(data, elapsed: ProcessInfo.processInfo.systemUptime - started) + } + + XCTAssertFalse(result.timedOut, "A healthy producer emits advancing packets throughout a run longer than the inactivity window") + XCTAssertTrue(result.succeeded, result.standardError) + XCTAssertGreaterThan(ProcessInfo.processInfo.systemUptime - started, 1.2) + XCTAssertGreaterThanOrEqual(observed.snapshot().callbackCount, 3) + XCTAssertTrue(result.standardOutput.isEmpty, "Realtime delivery must work without retaining conversion stdout") + } + + @MainActor + func testRealtime1080pTo720pShowsProgressBeforePublishingAndDecodes() async throws { + let ffmpeg = try availableFFmpeg() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("MP4Flow-RealtimeProgress-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("synthetic 1080p.mp4") + let encoders = await FFmpegProcessRunner(executable: ffmpeg, arguments: ["-hide_banner", "-encoders"]).run() + let encoderText = String(decoding: encoders.standardOutput, as: UTF8.self) + let encoder: String + if encoderText.contains(" libx264 ") { encoder = "libx264" } + else if encoderText.contains(" h264_videotoolbox ") { encoder = "h264_videotoolbox" } + else { throw XCTSkip("An H.264 encoder is required to generate the synthetic fixture") } + + var arguments = [ + "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", "testsrc2=size=1920x1080:rate=24:duration=3", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=3", + "-c:v", encoder + ] + if encoder == "libx264" { arguments += ["-preset", "ultrafast"] } + else { arguments += ["-b:v", "4M"] } + arguments += ["-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", "-y", source.path] + let fixture = await FFmpegProcessRunner(executable: ffmpeg, arguments: arguments).run() + XCTAssertTrue(fixture.succeeded, fixture.standardError) + guard fixture.succeeded else { return } + + let wrapper = directory.appendingPathComponent("ffmpeg") + let quotedFFmpeg = "'" + ffmpeg.path.replacingOccurrences(of: "'", with: "'\\''") + "'" + // Replace the shell with the real process; no descendant keeps the + // progress pipe open or escapes the converter's cancellation owner. + try "#!/bin/sh\nexec \(quotedFFmpeg) -re \"$@\"\n".write(to: wrapper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: wrapper.path) + let ffprobe = ffmpeg.deletingLastPathComponent().appendingPathComponent("ffprobe") + try FileManager.default.createSymbolicLink(at: directory.appendingPathComponent("ffprobe"), withDestinationURL: ffprobe) + + let store = ConverterStore(ffmpegOverride: wrapper) + store.outputResolution = .p720 + store.outputDirectory = directory + store.items = [ConversionItem(source: source)] + let started = ProcessInfo.processInfo.systemUptime + store.start() + var firstLiveProgress: TimeInterval? + let deadline = Date().addingTimeInterval(15) + while store.isRunning && Date() < deadline { + if case .running(let progress) = store.items[0].state, + let progress, progress > 0, progress < 0.999, + store.items[0].phase == .hardwareTranscoding || store.items[0].phase == .softwareTranscoding { + firstLiveProgress = firstLiveProgress ?? ProcessInfo.processInfo.systemUptime - started + } + try await Task.sleep(nanoseconds: 20_000_000) + } + if store.isRunning { + store.cancel() + XCTFail("Synthetic realtime conversion did not finish within its deadline") + return + } + let firstProgress = try XCTUnwrap(firstLiveProgress, "Conversion must show positive progress during transcoding, before verification/publication") + print(String(format: "Synthetic 1080p-to-720p first positive progress: %.3f s", firstProgress)) + XCTAssertLessThan(firstProgress, 2.8) + guard case .complete(let output) = store.items[0].state else { + return XCTFail("Expected a complete 720p output; got \(store.items[0].state)") + } + let info = await ConverterStore.inspectMedia(at: output, using: ffprobe) + XCTAssertTrue(info.probeSucceeded) + XCTAssertEqual(info.width, 1280) + XCTAssertEqual(info.height, 720) + XCTAssertEqual(info.audioCodec, "aac") + XCTAssertEqual(info.duration ?? 0, 3, accuracy: 0.15) + let decoded = await FFmpegProcessRunner( + executable: ffmpeg, + arguments: ["-v", "error", "-i", output.path, "-map", "0:v:0", "-map", "0:a:0?", "-f", "null", "-"] + ).run() + XCTAssertTrue(decoded.succeeded, "The published video and audio must decode completely: \(decoded.standardError)") + } + + private func periodicProgressScript(packetCount: Int, interval: Double) -> String { + """ + $| = 1; + for my $frame (1..\(packetCount)) { + my $time = $frame * 100000; + my $state = $frame == \(packetCount) ? 'end' : 'continue'; + print "frame=$frame\\nout_time_us=$time\\nprogress=$state\\n"; + select(undef, undef, undef, \(interval)); + } + """ + } + + private func systemPerl() throws -> URL { + let perl = URL(fileURLWithPath: "/usr/bin/perl") + guard FileManager.default.isExecutableFile(atPath: perl.path) else { + throw XCTSkip("System Perl is required for the single-process periodic-output fixture") + } + return perl + } + + private func availableFFmpeg() throws -> URL { + for path in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/local/bin/ffmpeg"] { + let ffmpeg = URL(fileURLWithPath: path) + if FileManager.default.isExecutableFile(atPath: path), + FileManager.default.isExecutableFile(atPath: ffmpeg.deletingLastPathComponent().appendingPathComponent("ffprobe").path) { + return ffmpeg + } + } + throw XCTSkip("FFmpeg and ffprobe are required for the synthetic conversion test") + } +} + +private final class RealtimeProgressObservations: @unchecked Sendable { + private let lock = NSLock() + private var firstCallback: TimeInterval? + private var callbackCount = 0 + private var text = "" + + func record(_ data: Data, elapsed: TimeInterval) { + lock.lock() + if firstCallback == nil { firstCallback = elapsed } + callbackCount += 1 + text += String(decoding: data, as: UTF8.self) + lock.unlock() + } + + func snapshot() -> (firstCallback: TimeInterval?, callbackCount: Int, text: String) { + lock.lock() + defer { lock.unlock() } + return (firstCallback, callbackCount, text) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f5ce65e..0425b0a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -3,7 +3,8 @@ 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. +- `ConverterStore.swift` owns the serial queue, conversion strategy, progress parsing, media probing, output naming, and merge logic. +- `FFmpegProcessRunner.swift` owns each FFmpeg/ffprobe process, pipe drains, cancellation and progress-stall watchdog. - `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. @@ -12,4 +13,14 @@ MP4Flow is a small native SwiftUI application without a server. 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. +Both pipes are drained through EOF while each child runs; waiting for `ffprobe` to exit before reading its JSON can deadlock on stream-rich media. Inspection, conversion and both merge modes share ownership and cancellation. Encoding watchdogs require advancing media time, frames or bytes; repeated progress packets and stderr noise do not hide a stall. The watchdog uses a monotonic clock and stops when its process exits. + +Drains use POSIX pipe reads, retrying `EINTR`, so small writes reach the progress parser immediately. Foundation's `read(upToCount:)` can accumulate a requested buffer on macOS and delay progress until EOF; large-output/finished-file tests alone do not catch this. Editor inspection uses the same cancellable runner with a short deadline, and dismissal cancels its owned task. + +Conversion and merge write to uniquely named partial MP4s, verify them, and publish only if the attempt has not been cancelled. Retryable probe failures are restricted to cloud and nonlocal sources. Encoding fallback requires a specific VideoToolbox component failure; media, muxing, I/O and timeout failures do not trigger automatic full-file replays. Each conversion/merge attempt has its own progress identity so delayed callbacks cannot update a later retry. + +For ordinary transcoding, accurate input seeking avoids decoding a discarded prefix, and a validated numeric source time base preserves VFR timing. Resize planning uses displayed orientation and post-crop dimensions. Smart Convert can copy unchanged compatible video, including an audio-only AAC conversion when no pixel edit or trim is requested; explicit encoding presets retain their selected behavior. + +Lossless merge compatibility includes the video time base, pixel aspect ratio and display rotation. Compatible merge normalizes pixel aspect ratio before proportional fitting and padding, and chooses its canvas from displayed geometry so orientation metadata cannot squash the content. + +User notices describe actual affected media, not routine mapping policy. Ordinary single-track and video-only inputs have no per-row notice. Extra video/audio, subtitles, chapters and at-risk HDR handling remain visible after completion; full default policy is accessible from the queue and environment details. Cancellation and output verification have distinct UI states. Controlled native-enhancement errors are classified by type rather than their translated message. diff --git a/docs/GITHUB_METADATA.md b/docs/GITHUB_METADATA.md index 3600278..d210d41 100644 --- a/docs/GITHUB_METADATA.md +++ b/docs/GITHUB_METADATA.md @@ -22,8 +22,9 @@ of the product's public download path, not optional decoration. capture. - Link the latest release rather than a versioned DMG in README text. This avoids leaving visitors on a stale installer after the next release. -- Attach a `.dmg` and matching `.sha256` file to every stable release. Do not - call a test, ad-hoc-signed, or unnotarized build a public release. +- Attach a verified `.dmg` and matching `.sha256` file to every release. + Disclose the actual signing and notarization status; do not present an + ad-hoc build as Apple-verified or notarized, or an untested build as stable. - Use the [issue chooser](https://github.com/ixiehao/MP4Flow/issues/new/choose) for reproducible bugs and feature requests. Keep security reports out of public issues. @@ -52,4 +53,4 @@ The website publishes this same card through Open Graph and Twitter metadata. Upload it in the repository's GitHub social-preview setting as well, so GitHub links and the website share one recognisable visual identity. -Create GitHub releases from signed and notarized DMGs only. Attach the `.dmg` and its matching `.sha256` file, then include the matching section from `CHANGELOG.md` in the release notes. Complete [RELEASE-CHECKLIST.md](RELEASE-CHECKLIST.md) before publishing. +The current release workflow uses ad-hoc signing without notarization and does not require a Developer ID certificate. Publish only after the relevant checks in [RELEASE-CHECKLIST.md](RELEASE-CHECKLIST.md), attach the verified `.dmg` and matching `.sha256` file, and summarize the matching `CHANGELOG.md` section in all three languages. State the unnotarized status and any remaining test limitations accurately. diff --git a/docs/INSTALLER-BUILD.md b/docs/INSTALLER-BUILD.md index 6cf8f3e..5663444 100644 --- a/docs/INSTALLER-BUILD.md +++ b/docs/INSTALLER-BUILD.md @@ -5,7 +5,7 @@ ## Prerequisites - Xcode Command Line Tools -- Python 3 with `venv` +- Python 3.10 or newer with `venv`; set `PACKAGING_PYTHON` to select an executable - Network access on the first run to install the pinned `dmgbuild` dependency - FFmpeg is not required to build the app or DMG @@ -17,6 +17,16 @@ The artifact is written to `dist/MP4Flow-.dmg` with a SHA-256 file beside it. The script builds a universal binary on Apple Silicon and an architecture-native binary on Intel, signs the app ad-hoc, validates the signature, and runs `hdiutil verify`. -## Publish safely +For a test build, use a dedicated output directory to preserve existing installers: -The generated DMG is suitable for internal testing, not public distribution. Before publishing, replace the ad-hoc signing step with your Apple Developer ID Application certificate, sign every nested executable, submit the DMG for notarization, staple the ticket, and verify it on a clean Mac. Do not ship a DMG that has not passed Gatekeeper testing. +```sh +OUTPUT_DIR="$PWD/dist/local-test" ./Scripts/package-dmg.sh +``` + +The app is staged in a unique temporary directory, so this command does not replace a developer's existing `MP4Flow.app`. Choose a new output directory when retaining an earlier test build with the same version. + +## Signing and distribution + +The current distribution uses ad-hoc signing and is not notarized; no Developer ID certificate is required for this workflow. Verify the final mounted app with `codesign --verify --deep --strict --verbose=2`, check both architectures when shipping a universal build, and test launch, dependency detection and conversion from the packaged app. An ad-hoc signature verifies the bundle's integrity but does not establish an Apple-verified publisher identity. Gatekeeper may require the user to approve the downloaded app in macOS Privacy & Security; describe that limitation accurately without recommending that users disable Gatekeeper globally. + +If Developer ID signing is adopted later, sign nested executable code first, sign the app, notarize the final distribution, staple the ticket, and verify Gatekeeper on a clean Mac. Generate the checksum only after the final artifact is complete. Do not label an ad-hoc build as notarized. diff --git a/docs/MEDIA-REGRESSION.md b/docs/MEDIA-REGRESSION.md index 9a1692f..7979472 100644 --- a/docs/MEDIA-REGRESSION.md +++ b/docs/MEDIA-REGRESSION.md @@ -13,14 +13,46 @@ 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, +`swift test` exercises `ConverterStore` with generated videos: default 1080p-to-720p +conversion, unchanged-size smart stream copy, audio-only conversion, late accurate +trimming, VFR presentation timestamps, failed-source retry, and trim/crop/rotation +geometry. Non-square-pixel fixtures check displayed proportions through software +and hardware rotation. Merge tests reject incompatible clocks/orientations and +measure a displayed square inside the output, rather than checking only canvas +dimensions. Process tests exercise large stdout/stderr through EOF, +stalled progress, cancellation during inspection and a live merge, retry cleanup, 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. +Realtime tests additionally require small progress packets to arrive while the +producer is still running, verify that regularly advancing packets prevent false +inactivity timeouts, and observe positive 1080p-to-720p queue progress before +publication. Editor-preview tests cover malformed responses and cancellation of +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. + +### Optional local performance check + +The queue benchmark is skipped by default. To measure a full authorized local +video through the actual application queue, run: + +```sh +MP4FLOW_BENCHMARK_INPUT="/path/to/authorized/video.mp4" \ + swift test --filter ConverterStoreTests.testAuthorizedMedia720pBenchmark +``` + +It selects Smart Convert and 720p, writes into a unique temporary directory, +checks duration/frame count and full decoding, then removes the output. The +reported elapsed time ends after the app's normal output verification; the +additional test decode is excluded. The source path is not printed or stored in +the repository. Keep private media and probe metadata out of public reports. +Record the chip, FFmpeg version, input facts and chosen route with performance +results; this is a measured diagnostic, not a CI speed threshold. + 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 @@ -49,6 +81,7 @@ the results and tool versions—not the source media or its full path. | HDR source | The UI says whether HDR is retained, tone-mapped, or unsupported before start. | | Odd dimensions / unusual pixel aspect ratio | Output has the expected displayed aspect ratio and is not stretched. | | 30+ minute source | Progress advances, cancellation responds, and the app remains responsive. | +| A normal job lasting longer than the inactivity window | Live small progress packets prevent a false timeout; first progress appears before completion. | | File name with spaces, quotes, Unicode, and brackets | Source is untouched; output succeeds without shell-path errors. | | Truncated / damaged source | Conversion fails quickly with a reason and recovery guidance; no fake success. | | Network/removable volume | A disconnect or write failure is recoverable; no partial file is presented as final. | @@ -75,6 +108,13 @@ been checked: or ordinary input/output failure. - A progress stall has an observable process/status explanation and a bounded recovery path; a completed process is always followed by output validation. +- Watch an actual installed candidate during a full customer-authorized job: + record the first positive progress time and intermediate percentages, not + just the final duration. Verification/cancellation must not be labelled as + continued transcoding, and 100% must not imply unchecked output is published. +- Ordinary single-video/single-audio and video-only inputs do not show routine + orange warnings. The policy remains accessible, real losses remain visible, + and failure recovery text is readable in all supported languages. - Source files remain unchanged. Failed jobs leave no user-visible final output and no retained `.partial.mp4` file. - Track and HDR handling is explicit. The app must never silently promise that diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index ac70215..78db9cf 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -8,7 +8,7 @@ Use this checklist for each public MP4Flow release. It is a release gate, not a - [ ] Enable GitHub Private Vulnerability Reporting and replace the reporting instruction in [SECURITY.md](../SECURITY.md) with the repository's private reporting link. - [ ] Confirm the repository description, topics, licence, default branch protection, and release notes follow [GITHUB_METADATA.md](GITHUB_METADATA.md). - [ ] Confirm the repository homepage, trilingual README download links, issue chooser, and social preview still point to the current official project surfaces. -- [ ] Confirm the latest Release has a human-readable title, English, Japanese, and Simplified Chinese user-facing notes, a signed/notarized DMG, and a matching SHA-256 file. +- [ ] Confirm the latest Release has a human-readable title, English, Japanese, and Simplified Chinese user-facing notes, a DMG with its actual signing/notarization status disclosed, and a matching SHA-256 file. - [ ] Check that `.gitignore` is effective and that the branch contains no signing certificates, provisioning profiles, private keys, `.env` files, test media, DMGs, or build products. - [ ] Review issues, pull requests, screenshots, and logs for private file paths, personal data, or unlicensed media. @@ -21,6 +21,8 @@ Use this checklist for each public MP4Flow release. It is a release gate, not a - [ ] Run `./Scripts/run-media-regression.sh`; retain the command output with the private release record, not the generated media. - [ ] Complete the real-device matrix in [MEDIA-REGRESSION.md](MEDIA-REGRESSION.md), including VFR, portrait/rotation metadata, audio/subtitle policies, HDR behaviour, long inputs, damaged inputs, unusual dimensions, and output-storage failures. - [ ] Confirm the app names the planned conversion route and does not imply that secondary audio, subtitles, metadata, chapters, or HDR were retained when its selected route removes or changes them. +- [ ] Observe positive progress while FFmpeg is still running on a long-enough sample; small packets must not wait until EOF or cause a false inactivity timeout. Check cancelling, verification and completed states as well as final output. +- [ ] Check ordinary files have no redundant warning rows, actual loss notices remain readable after completion, policy details are keyboard accessible, and editor-load failures can be dismissed with recovery guidance in all three languages. - [ ] Run a clean-machine test with a trusted separately installed FFmpeg build. Record the FFmpeg version in release validation notes. ## Licences, copyright, and content @@ -33,15 +35,15 @@ Use this checklist for each public MP4Flow release. It is a release gate, not a ## Build, signing, and distribution - [ ] Run `swift build -c release` and `./Scripts/validate-localizations.sh`. -- [ ] Build the DMG with `./Scripts/package-dmg.sh`; it is ad-hoc signed and suitable only for internal testing until the next steps are complete. -- [ ] Re-sign the app and every nested executable with a Developer ID Application certificate. Verify with `codesign --verify --deep --strict --verbose=2 MP4Flow.app`. -- [ ] Submit the final DMG for Apple notarization, staple the ticket, then assess it on a clean Mac with `spctl --assess --type open --context context:primary-signature `. -- [ ] Generate and publish the SHA-256 file for the exact notarized DMG. Treat a checksum as integrity information, not as a signing substitute. +- [ ] Build the DMG with `./Scripts/package-dmg.sh`; use a dedicated `OUTPUT_DIR` for a local test build to preserve existing artifacts. The current workflow signs the app ad-hoc and requires no Developer ID certificate. +- [ ] Verify the mounted final app with `codesign --verify --deep --strict --verbose=2 MP4Flow.app`, confirm the claimed architectures and bundled resources, and disclose that the current build is not notarized. +- [ ] Test the downloaded app's Gatekeeper behaviour on a clean Mac and provide accurate macOS approval instructions. If a future release uses Developer ID, sign nested executable code and the app, notarize and staple the final distribution, and verify the resulting ticket before claiming notarization. +- [ ] Generate and publish the SHA-256 file for the exact final DMG. Treat a checksum as integrity information, not as a signing substitute. - [ ] Install the final DMG on a clean Intel Mac and Apple-silicon Mac when both architectures are claimed. Test first launch, Gatekeeper, English, Japanese, Simplified Chinese, FFmpeg missing, conversion, trim, crop, rotation, and both merge modes. ## Release notes and support - [ ] Add an accurate dated section to [CHANGELOG.md](../CHANGELOG.md), including known limitations and upgrade notes. -- [ ] Attach only the notarized DMG and matching checksum to the GitHub release. +- [ ] Attach the verified final DMG and matching checksum to the GitHub release, with accurate ad-hoc or Developer ID/notarization details. - [ ] Verify all README links, the demo GIF, support guidance, privacy policy, notices, and security reporting link after publishing. - [ ] Keep a private release record with macOS version, Xcode/Swift version, FFmpeg version used for tests, signing identity, notarization submission ID, checksum, and validation results. Do not publish sensitive identifiers. From fbea7be452036046fe8f091f8873ce6d6396e827 Mon Sep 17 00:00:00 2001 From: hao Date: Sun, 18 Oct 2026 14:44:51 +0800 Subject: [PATCH 2/2] Fix merge geometry type checking on older Swift compilers --- Sources/FastConvert/ConverterStore.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/FastConvert/ConverterStore.swift b/Sources/FastConvert/ConverterStore.swift index 5568c91..e4a3f8e 100644 --- a/Sources/FastConvert/ConverterStore.swift +++ b/Sources/FastConvert/ConverterStore.swift @@ -1153,7 +1153,13 @@ final class ConverterStore: ObservableObject { let geometry = VideoGeometry.displayed(width: normalizedWidth, height: height, sampleAspectRatio: "1:1", rotation: info.displayRotation) return (info, even(geometry?.width ?? normalizedWidth), even(geometry?.height ?? height)) } - let largest = displayed.max { Double($0.width) * Double($0.height) < Double($1.width) * Double($1.height) }! + // Keep tuple comparison simple enough for the older Swift toolchain + // used by CI; area is calculated in Double to avoid Int overflow. + let largest = displayed.max { left, right in + let leftArea = Double(left.width) * Double(left.height) + let rightArea = Double(right.width) * Double(right.height) + return leftArea < rightArea + }! return CompatibleMergeTarget( width: largest.width, height: largest.height,