feat(video,ios): 接入 iOS 端 ffmpeg-kit 转码桥

- 新增 Assets/Plugins/iOS/FFmpegKitIOS.mm: ObjC++ extern C 桥,调 ffmpeg-kit executeWithArguments 做 libx264 重编码

- FFmpegKitBridge.cs 新增 UNITY_IOS 分支,经 DllImport(__Internal) 后台线程调用,参数与 Android 一致

- 新增 Assets/Plugins/iOS/FFmpegKit/README: 说明需放入的 ffmpegkit.xcframework 及构建来源

- 注: xcframework 需 macOS 自编译(arthenica ffmpeg-kit ios.sh --full --enable-gpl -x),放好前 .mm 链接头文件编译期报错属预期
This commit is contained in:
“虞渠成” 2026-09-08 14:10:41 +08:00
parent 4726107e19
commit 52a3e9f06c
6 changed files with 241 additions and 4 deletions

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a31fe5a5335a6a84592ed4c2b335e1f0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,52 @@
# iOS ffmpeg-kit 依赖放置说明
iOS 端转码复用 Android 同一套转码逻辑(libx264 重编码修复坏 PPS 裸流)。
与 Android 的 `.so` 不同,iOS 用 ffmpeg-kit 的 **`.xcframework`**(内含 `ffmpegkit.framework`),
放在本目录后 Unity 会在构建 Xcode 工程时**自动链接**。
## 需要放入的文件
把 ffmpeg-kit iOS **full-gpl** 的 xcframework(含 libx264)解压后放进本目录,例如:
```
Assets/Plugins/iOS/FFmpegKit/
└── ffmpegkit.xcframework/
├── Info.plist
├── ios-arm64_armv7/ (真机切片)
│ └── ffmpegkit.framework/
└── ios-arm64_i386_x86_64-simulator/ (模拟器切片,可选)
└── ffmpegkit.framework/
```
(不同版本/项目解压出的骨架可能略有不同,只要有一个 `ffmpegkit.xcframework` 即可。)
## 版本要求
与 Android 端保持一致,避免转码行为差异:
- 来源: `dev.ffmpegkit-maintained:ffmpeg-kit-full-gpl:6.0.3`(FFmpeg **n6.1.6**,含 libx264)
- 必须是 **full-gpl**(含有)的 iOS 包;free/min 等版没有 libx264,转码会失败。
- 若拿不到完全同 6.0.3 的 iOS 包,取同源(dev.ffmpegkit-maintained)最新 full-gpl 的 iOS
包也可,但需确认 ffmpeg 仍能 `-c:v libx264`
## 获取方式(macOS 上操作)
1. 下载 ffmpeg-kit 维护版发布的 iOS full-gpl xcframework 压缩包
(对应 Android AAR 那个 `ffmpeg-kit-full-gpl-6.0.3.aar` 的同批次 iOS 产物)。
2. 解压得到 `ffmpegkit.xcframework`,拷入本目录。
3. Unity 重新 import(会自动生成 `.meta`)。
> 注:ffmpeg-kit 的 Apple 产物需在 macOS + Xcode 下构建/发布,Windows 无法直接获得,
> 故本目录仅空占位 + 说明,实际 xcframework 需你在 Mac 上获取后放入。
## 调用链
- C# 桥: `Assets/Scripts/Video/FFmpegKitBridge.cs``#elif UNITY_IOS && !UNITY_EDITOR` 分支
- `[DllImport("__Internal")]` 调本目录原生符号 `FFmpegKitExecuteWithArguments`
- 真正实现是 `FFmpegKitIOS.mm`(在 `Assets/Plugins/iOS/`,同目录上级)
- 下载/播放服务: `Assets/Scripts/Video/VideoDownloadPlayService.cs`(平台无关,已兼容)
## 验证
- 转码日志应出现 `[FFmpegKitBridge] args: ... -c:v libx264 ...``[FFmpegKitBridge] rc=0`
- 生成的 mp4 可正常播放、不卡帧(与 Android 行为一致)

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 1fee61c27619655488da70cf0a955125
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,65 @@
//
// FFmpegKitIOS.mm
// iOS 端 .264 -> .mp4 转码桥(extern "C"),供 C# 经 [DllImport("__Internal")] 调用。
//
// 原理: 调用 native ffmpeg-kit(ffmpegkit.framework)的 executeWithArguments,用 libx264
// 重编码重新生成干净的 SPS/PPS + 时间戳,修复设备(OPPO/MTK 等)产出裸流坏 PPS 导致的
// 解码卡死("non-existing PPS 0 referenced")。
//
// 依赖: 需将 ffmpeg-kit iOS full-gpl 的 .xcframework(含 ffmpegkit.framework)放进
// Assets/Plugins/iOS/FFmpegKit/(Unity 会在构建 Xcode 工程时自动链接)。
// 版本需与本项目 Android 端一致: dev.ffmpegkit-maintained:ffmpeg-kit-full-gpl:6.0.3
// (FFmpeg n6.1.6,含 libx264)。缺少该 framework 时本文件编译期即报错找不到头文件,属预期。
//
#import <ffmpegkit/FFmpegKit.h>
#import <ffmpegkit/FFmpegKitConfig.h>
#import <Foundation/Foundation.h>
#import <signal.h>
static BOOL ffmpegKitConfigured = NO;
// 一次性配置:忽略 Unity/IL2CPP 运行时可能被 ffmpeg-kit 信号处理干扰的信号
static void FFmpegKitConfigureOnce(void) {
if (ffmpegKitConfigured) return;
ffmpegKitConfigured = YES;
[FFmpegKitConfig ignoreSignal:SIGXCPU];
[FFmpegKitConfig ignoreSignal:SIGPIPE];
}
extern "C" {
/// 执行 ffmpeg 转码。
/// @param argv 参数数组(C# string[] 会作为 UTF-8 char** 传入)
/// @param argc 参数个数
/// @return 0=成功;否则为 ffmpeg 退出码;-1=参数或内部错误
int FFmpegKitExecuteWithArguments(char **argv, int argc) {
@autoreleasepool {
FFmpegKitConfigureOnce();
if (argv == NULL || argc <= 0) {
NSLog(@"[FFmpegKitIOS] bad args argv=%p argc=%d", argv, argc);
return -1;
}
NSMutableArray *arguments = [NSMutableArray arrayWithCapacity:(NSUInteger)argc];
for (int i = 0; i < argc; i++) {
if (argv[i] == NULL) continue;
[arguments addObject:[NSString stringWithUTF8String:argv[i]]];
}
NSLog(@"[FFmpegKitIOS] execute: %@", [arguments componentsJoinedByString:@" "]);
FFmpegSession *session = [FFmpegKit executeWithArguments:arguments];
ReturnCode *returnCode = [session getReturnCode];
if ([ReturnCode isSuccess:returnCode]) {
return 0;
}
if (returnCode == nil) {
NSLog(@"[FFmpegKitIOS] returnCode nil, treat as failure.");
return -1;
}
return (int)[returnCode getValue];
}
}
}

View File

@ -0,0 +1,33 @@
fileFormatVersion: 2
guid: 8963e0eb06dd1fe418eeae84f183fd4a
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
Any:
second:
enabled: 0
settings: {}
- first:
Editor: Editor
second:
enabled: 0
settings:
DefaultValueInitialized: true
- first:
iPhone: iOS
second:
enabled: 1
settings:
AddToEmbeddedBinaries: false
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,5 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kill.Utils; using Kill.Utils;
using UnityEngine; using UnityEngine;
@ -7,12 +8,17 @@ using UnityEngine;
namespace Kill.Video namespace Kill.Video
{ {
/// <summary> /// <summary>
/// ffmpeg-kit-maintained(维护版,群组 dev.ffmpegkit-maintained)C# 桥接 /// ffmpeg-kit(维护版,群组 dev.ffmpegkit-maintained)C# 桥接。
/// 用 AndroidJNI 底层 API 直接调,绕开 AndroidJavaClass.CallStatic 的 Unity bug(Ljava.lang.Object)
/// .264 -> .mp4 用 libx264 重编码(full-gpl 版),补齐干净的 SPS/PPS + 时间戳, /// .264 -> .mp4 用 libx264 重编码(full-gpl 版),补齐干净的 SPS/PPS + 时间戳,
/// 供各手机(含 OPPO/MTK)解码器正常播放;纯 copy 会复制坏帧导致播放卡死。 /// 供各手机(含 OPPO/MTK)解码器正常播放;纯 copy 会复制坏帧导致播放卡死。
/// 注意:所有 JNI 调用必须在 Unity 主线程执行(IL2CPP 工作线程 JVM attach 不可靠,FindClass ///
/// 偶发失败);现依赖为 ffmpeg-kit-full-gpl(含 libx264),free 版没有该编码器。 /// 平台差异:
/// - Android: 用 AndroidJNI 底层 API 直接调,绕开 AndroidJavaClass.CallStatic 的 Unity
/// bug(Ljava.lang.Object);所有 JNI 必须在 Unity 主线程执行(IL2CPP 工作线程 JVM attach
/// 不可靠,FindClass 偶发失败),经 MainThreadDispatcher 派发。
/// - iOS: 经 Assets/Plugins/iOS/FFmpegKitIOS.mm(extern "C")调 native ffmpeg-kit,
/// [DllImport("__Internal")] 直接在后台上线程执行,无 JNI 约束。
/// 现依赖 ffmpeg-kit-full-gpl(含 libx264),free 版没有该编码器。
/// </summary> /// </summary>
public static class FFmpegKitBridge public static class FFmpegKitBridge
{ {
@ -253,7 +259,73 @@ namespace Kill.Video
} }
#endregion #endregion
#elif UNITY_IOS && !UNITY_EDITOR
// iOS: 经 Assets/Plugins/iOS/FFmpegKitIOS.mm(extern "C") 调 native ffmpeg-kit,
// 结构与 Android 相同,但无需 JNI/主线程约束,直接在后台线程执行不阻塞 UI。
[DllImport("__Internal")]
private static extern int FFmpegKitExecuteWithArguments(string[] args, int argc);
/// <summary>异步 .264 -> .mp4 重编码转码(iOS)</summary>
public static Task<bool> ConvertH264ToMp4Async(string inputPath, string outputPath)
{
if (string.IsNullOrEmpty(inputPath) || !File.Exists(inputPath))
return Task.FromResult(false);
if (File.Exists(outputPath))
{
try { File.Delete(outputPath); } catch { }
}
// 与 Android 完全一致的参数:libx264 重编码重新生成干净的 SPS/PPS + 时间戳,
// 修复坏 PPS 裸流导致的解码卡死("non-existing PPS 0 referenced")
string[] args = new string[] {
"-y",
"-loglevel", "quiet",
"-fflags", "+genpts",
"-i", inputPath,
"-c:v", "libx264",
"-preset", "ultrafast",
"-crf", "23",
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
outputPath
};
Debug.Log("[FFmpegKitBridge] args: " + string.Join(" ", args));
return Task.Run(() =>
{
try
{
int rc = FFmpegKitExecuteWithArguments(args, args.Length);
Debug.Log("[FFmpegKitBridge] rc=" + rc);
bool ok = rc == 0;
// 兜底:rc 非 0 但 mp4 已写出且 >0,(与 Android 一致的 signal 场景)按成功处理
if (!ok)
{
try
{
if (File.Exists(outputPath))
{
var fi = new FileInfo(outputPath);
if (fi.Length > 0)
{
Debug.LogWarning("[FFmpegKitBridge] ffmpeg rc=" + rc + " 但 mp4 已生成,按成功处理");
ok = true;
}
}
}
catch { }
}
return ok;
}
catch (Exception e)
{
Debug.LogError("[FFmpegKitBridge] execute fail: " + e);
return false;
}
});
}
#else #else
// 编辑器 / 其他平台:不执行转码
public static Task<bool> ConvertH264ToMp4Async(string inputPath, string outputPath) public static Task<bool> ConvertH264ToMp4Async(string inputPath, string outputPath)
{ {
return Task.FromResult(false); return Task.FromResult(false);