- 集成 ffmpeg-kit-full-gpl 6.0.3(arm64),用 libx264 重编码修复 .h264 缺 SPS/PPS 导致的播放卡死 - 新增 VideoDownloadPlayService/VideoCacheManager,.mp4 直下直播、.h264 下载后转 mp4 缓存,转码完成后删除临时 .h264 - FFmpegKitBridge 走主线程 JNI 调用规避 IL2CPP 后台线程 FindClass 不稳定 - 视频列表按缓存状态显示 下载/播放 按钮,下载中亮色条显示百分比;播放面板原下载按钮改为保存到相册
263 lines
12 KiB
C#
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
|
|
}
|
|
} |