killapp/Assets/Scripts/Video/FFmpegKitBridge.cs
“虞渠成” 4726107e19 feat(video,android): 视频下载转码播放 + 列表下载/播放按钮
- 集成 ffmpeg-kit-full-gpl 6.0.3(arm64),用 libx264 重编码修复 .h264 缺 SPS/PPS 导致的播放卡死

- 新增 VideoDownloadPlayService/VideoCacheManager,.mp4 直下直播、.h264 下载后转 mp4 缓存,转码完成后删除临时 .h264

- FFmpegKitBridge 走主线程 JNI 调用规避 IL2CPP 后台线程 FindClass 不稳定

- 视频列表按缓存状态显示 下载/播放 按钮,下载中亮色条显示百分比;播放面板原下载按钮改为保存到相册
2026-09-08 13:31:31 +08:00

263 lines
12 KiB
C#

using System;
using System.IO;
using System.Threading.Tasks;
using Kill.Utils;
using UnityEngine;
namespace Kill.Video
{
/// <summary>
/// ffmpeg-kit-maintained(维护版,群组 dev.ffmpegkit-maintained)C# 桥接
/// 用 AndroidJNI 底层 API 直接调,绕开 AndroidJavaClass.CallStatic 的 Unity bug(Ljava.lang.Object)
/// .264 -> .mp4 用 libx264 重编码(full-gpl 版),补齐干净的 SPS/PPS + 时间戳,
/// 供各手机(含 OPPO/MTK)解码器正常播放;纯 copy 会复制坏帧导致播放卡死。
/// 注意:所有 JNI 调用必须在 Unity 主线程执行(IL2CPP 工作线程 JVM attach 不可靠,FindClass
/// 偶发失败);现依赖为 ffmpeg-kit-full-gpl(含 libx264),free 版没有该编码器。
/// </summary>
public static class FFmpegKitBridge
{
#if UNITY_ANDROID && !UNITY_EDITOR
/// <summary>
/// 异步 .264 -> .mp4 纯封装
/// </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 { }
}
// 设备产出的 .264 裸流 SPS/PPS 引用损坏,纯 -c:v copy 会复制坏帧导致
// 播放器解码卡死("non-existing PPS 0 referenced")。必须是重编码(libx264)
// 重新生成干净的 SPS/PPS + 时间戳,等价 service 端 VideoTranscodeService。
// 注意:需要 ffmpeg-kit-full(含 libx264);free 版无 libx264 编码器会失败。
// preset ultrafast 更快速;crf 值控制质量,视频短可接受
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(() =>
{
bool result = false;
// JNI 必须在主线程执行,否则 FindClass 偶发失败("类未找到")
MainThreadDispatcher.RunOnMainThread(() => result = RunFfmpegConvert(args, outputPath));
return result;
});
}
/// <summary>在 Unity 主线程执行 ffmpeg JNI 调用(同步,阻塞)</summary>
private static bool RunFfmpegConvert(string[] args, string outputPath)
{
try
{
using (var ffkitClass = new AndroidJavaClass("com.arthenica.ffmpegkit.FFmpegKit"))
{
IntPtr classPtr = ffkitClass.GetRawClass();
if (classPtr == IntPtr.Zero)
{
Debug.LogError("[FFmpegKitBridge] FFmpegKit 类未找到(类不在 classpath,多半是装了旧 APK)");
return false;
}
// 主动 FindClass 二重确认类存在(区分"类缺失" vs "方法签名不匹配")
IntPtr findCls = AndroidJNI.FindClass("com/arthenica/ffmpegkit/FFmpegKit");
Debug.Log("[FFmpegKitBridge] FindClass => " + (findCls == IntPtr.Zero ? "NOT_FOUND" : "OK"));
// ffmpeg-kit 6.0+ 用 executeWithArguments(String[]) 而不是 execute(String)
IntPtr executeMethodID = AndroidJNI.GetStaticMethodID(
classPtr,
"executeWithArguments",
"([Ljava/lang/String;)Lcom/arthenica/ffmpegkit/FFmpegSession;");
if (executeMethodID == IntPtr.Zero)
{
Debug.LogError("[FFmpegKitBridge] executeMethodID == 0:类存在但方法 executeWithArguments 未找到(签名不匹配)");
return false;
}
// 构造 String[] 作为 JNI 参数
IntPtr argsArr = AndroidJNI.NewObjectArray(args.Length,
AndroidJNI.FindClass("java/lang/String"), IntPtr.Zero);
for (int i = 0; i < args.Length; i++)
{
IntPtr strPtr = AndroidJNI.NewStringUTF(args[i]);
AndroidJNI.SetObjectArrayElement(argsArr, i, strPtr);
AndroidJNI.DeleteLocalRef(strPtr);
}
jvalue[] jargs = new jvalue[1];
jargs[0].l = argsArr;
IntPtr sessionPtr = AndroidJNI.CallStaticObjectMethod(
classPtr, executeMethodID, jargs);
AndroidJNI.DeleteLocalRef(argsArr);
if (sessionPtr == IntPtr.Zero)
{
Debug.LogError("[FFmpegKitBridge] execute return null");
return false;
}
try { return ReadSessionResult(sessionPtr, outputPath); }
finally { AndroidJNI.DeleteLocalRef(sessionPtr); }
}
}
catch (Exception e)
{
Debug.LogError("[FFmpegKitBridge] execute fail: " + e);
return false;
}
}
/// <summary>读取 FFmpegSession 结果并解析成功与否</summary>
private static bool ReadSessionResult(IntPtr sessionPtr, string outputPath)
{
try
{
// 读 ReturnCode
IntPtr getReturnCodeMID = AndroidJNI.GetMethodID(
AndroidJNI.GetObjectClass(sessionPtr),
"getReturnCode", "()Lcom/arthenica/ffmpegkit/ReturnCode;");
IntPtr rcPtr = AndroidJNI.CallObjectMethod(sessionPtr, getReturnCodeMID, new jvalue[0]);
if (rcPtr == IntPtr.Zero)
{
Debug.LogError("[FFmpegKitBridge] returnCode null");
return false;
}
// ReturnCode.getValue() int
IntPtr rcClassPtr = AndroidJNI.GetObjectClass(rcPtr);
IntPtr getValueMID = AndroidJNI.GetMethodID(rcClassPtr, "getValue", "()I");
int rcValue = AndroidJNI.CallIntMethod(rcPtr, getValueMID, new jvalue[0]);
// ReturnCode.isValueSuccess() boolean
IntPtr isSuccessMID = AndroidJNI.GetMethodID(rcClassPtr, "isValueSuccess", "()Z");
bool isSuccess = AndroidJNI.CallBooleanMethod(rcPtr, isSuccessMID, new jvalue[0]);
Debug.Log("[FFmpegKitBridge] rc=" + rcValue + " isSuccess=" + isSuccess);
// ffmpeg-kit 6.0.2 在某些场景下 rc=255 (SIGURG) 但 ffmpeg 已写完 mp4
// 兜底:同时检查输出文件存在 + 大小 > 0
if (!isSuccess)
{
try
{
if (File.Exists(outputPath))
{
var fi = new FileInfo(outputPath);
if (fi.Length > 0)
{
Debug.LogWarning("[FFmpegKitBridge] ffmpeg exit signal 但 mp4 已生成 (" + fi.Length + " bytes),按成功处理");
isSuccess = true;
}
}
}
catch { }
}
// 失败时打印 ffmpeg 错误日志
if (!isSuccess)
{
try
{
IntPtr sessionClass = AndroidJNI.GetObjectClass(sessionPtr);
IntPtr failStackMID = AndroidJNI.GetMethodID(sessionClass, "getFailStackTrace", "()Ljava/lang/String;");
string failStack = AndroidJNI.CallStringMethod(sessionPtr, failStackMID, new jvalue[0]);
if (!string.IsNullOrEmpty(failStack))
Debug.LogError("[FFmpegKitBridge] failStack: " + failStack);
}
catch { }
try
{
IntPtr sessionClass = AndroidJNI.GetObjectClass(sessionPtr);
IntPtr logsMID = AndroidJNI.GetMethodID(sessionClass, "getAllLogsAsString", "()Ljava/lang/String;");
string logs = AndroidJNI.CallStringMethod(sessionPtr, logsMID, new jvalue[0]);
if (!string.IsNullOrEmpty(logs))
Debug.LogError("[FFmpegKitBridge] allLogs: " + logs);
}
catch { }
}
AndroidJNI.DeleteLocalRef(rcPtr);
return isSuccess;
}
catch (Exception e)
{
Debug.LogError("[FFmpegKitBridge] read result fail: " + e);
return false;
}
}
#region debug-point vf-1 (: mp4 //profile/level,"截断文件 / 时间戳错误 / 解码不支持")
/// <summary>
/// 用 ffprobe 读取 mp4 的媒体信息并以 JSON 原文打日志(证据收集,临时插桩)
/// </summary>
public static void ProbeMp4Info(string path)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return;
MainThreadDispatcher.RunOnMainThread(() =>
{
try
{
string[] args = new string[]
{
"-v", "error",
"-show_entries", "format=duration,size,bit_rate:stream=codec_name,profile,level,width,height,r_frame_rate,avg_frame_rate,nb_frames,time_base,start_time",
"-of", "json", path
};
using (var probeClass = new AndroidJavaClass("com.arthenica.ffmpegkit.FFprobeKit"))
{
IntPtr classPtr = probeClass.GetRawClass();
IntPtr mID = AndroidJNI.GetStaticMethodID(
classPtr,
"executeWithArguments",
"([Ljava/lang/String;)Lcom/arthenica/ffmpegkit/FFprobeSession;");
if (mID == IntPtr.Zero) { Debug.LogError("[ProbeMp4Info] no executeWithArguments"); return; }
IntPtr argsArr = AndroidJNI.NewObjectArray(args.Length,
AndroidJNI.FindClass("java/lang/String"), IntPtr.Zero);
for (int i = 0; i < args.Length; i++)
{
IntPtr s = AndroidJNI.NewStringUTF(args[i]);
AndroidJNI.SetObjectArrayElement(argsArr, i, s);
AndroidJNI.DeleteLocalRef(s);
}
jvalue[] ja = new jvalue[1]; ja[0].l = argsArr;
IntPtr sessionPtr = AndroidJNI.CallStaticObjectMethod(classPtr, mID, ja);
AndroidJNI.DeleteLocalRef(argsArr);
if (sessionPtr == IntPtr.Zero) { Debug.LogError("[ProbeMp4Info] session null"); return; }
try
{
IntPtr cls = AndroidJNI.GetObjectClass(sessionPtr);
IntPtr getOutMID = AndroidJNI.GetMethodID(cls, "getOutput", "()Ljava/lang/String;");
string outJson = AndroidJNI.CallStringMethod(sessionPtr, getOutMID, new jvalue[0]);
Debug.Log("[ProbeMp4Info] ffprobe json: " + (outJson ?? "null"));
}
finally { AndroidJNI.DeleteLocalRef(sessionPtr); }
}
}
catch (Exception e) { Debug.LogError("[ProbeMp4Info] fail: " + e); }
});
}
#endregion
#else
public static Task<bool> ConvertH264ToMp4Async(string inputPath, string outputPath)
{
return Task.FromResult(false);
}
#endif
}
}