using System; using System.IO; using System.Threading.Tasks; using Kill.Utils; using UnityEngine; namespace Kill.Video { /// /// 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 版没有该编码器。 /// public static class FFmpegKitBridge { #if UNITY_ANDROID && !UNITY_EDITOR /// /// 异步 .264 -> .mp4 纯封装 /// public static Task 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; }); } /// 在 Unity 主线程执行 ffmpeg JNI 调用(同步,阻塞) 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; } } /// 读取 FFmpegSession 结果并解析成功与否 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,用于区分"截断文件 / 时间戳错误 / 解码不支持") /// /// 用 ffprobe 读取 mp4 的媒体信息并以 JSON 原文打日志(证据收集,临时插桩) /// 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 ConvertH264ToMp4Async(string inputPath, string outputPath) { return Task.FromResult(false); } #endif } }