using System; using System.Diagnostics; using System.IO; using System.Text; using System.Threading; using UnityEditor; using UnityEngine.Video; using Debug = UnityEngine.Debug; namespace Chameleon.Editor { /// /// Right-click a VideoClip asset -> Process -> Ping-Pong Video. /// Runs ffmpeg (from PATH) to append the reversed clip (first reversed frame /// dropped to avoid a duplicate) and imports the result next to the original. /// Audio is played twice in sequence, not reversed. /// public static class VideoPingPongProcessor { const string MenuPath = "Assets/Process/Ping-Pong Video"; const string VideoFilter = "[0:v]split[v1][v2];[v2]reverse,trim=start_frame=1,setpts=PTS-STARTPTS[vr];" + "[v1][vr]concat=n=2:v=1:a=0[vout]"; const string AudioFilter = ";[0:a]asplit[a1][a2];[a1][a2]concat=n=2:v=0:a=1[aout]"; [MenuItem(MenuPath, true)] static bool Validate() => Selection.GetFiltered(SelectionMode.Assets).Length > 0; [MenuItem(MenuPath, false, 2000)] static void Process() { var mainThread = SynchronizationContext.Current; foreach (var clip in Selection.GetFiltered(SelectionMode.Assets)) ProcessClip(clip, mainThread); } static void ProcessClip(VideoClip clip, SynchronizationContext mainThread) { string assetPath = AssetDatabase.GetAssetPath(clip); if (string.IsNullOrEmpty(assetPath) || !File.Exists(assetPath)) { Debug.LogError($"[PingPong] No source file for '{clip.name}'."); return; } // Output is always .mp4 (libx264/aac), regardless of source container. string directory = Path.GetDirectoryName(assetPath)?.Replace('\\', '/'); string outputAssetPath = AssetDatabase.GenerateUniqueAssetPath( $"{directory}/{Path.GetFileNameWithoutExtension(assetPath)}_pingpong.mp4"); string inputFull = Path.GetFullPath(assetPath); string outputFull = Path.GetFullPath(outputAssetPath); int progressId = Progress.Start("Ping-Pong Video", Path.GetFileName(assetPath)); Run(inputFull, outputFull, withAudio: true, (success, log) => { // Source without an audio stream: retry with video-only graph. if (!success && log.Contains("matches no streams")) { Run(inputFull, outputFull, withAudio: false, (retrySuccess, retryLog) => mainThread.Post( _ => Finish(retrySuccess, retryLog, outputAssetPath, outputFull, progressId), null)); return; } mainThread.Post(_ => Finish(success, log, outputAssetPath, outputFull, progressId), null); }); } static void Run(string inputFull, string outputFull, bool withAudio, Action onDone) { string filter = withAudio ? VideoFilter + AudioFilter : VideoFilter; string maps = withAudio ? "-map \"[vout]\" -map \"[aout]\" -c:v libx264 -c:a aac" : "-map \"[vout]\" -c:v libx264"; var startInfo = new ProcessStartInfo { FileName = "ffmpeg", Arguments = $"-y -nostdin -i \"{inputFull}\" -filter_complex \"{filter}\" {maps} \"{outputFull}\"", UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, }; var log = new StringBuilder(); try { var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; process.ErrorDataReceived += (_, e) => { if (e.Data != null) log.AppendLine(e.Data); }; process.Exited += (_, _) => { bool success = process.ExitCode == 0; process.Dispose(); onDone(success, log.ToString()); }; process.Start(); process.BeginErrorReadLine(); } catch (Exception e) { onDone(false, $"Failed to start ffmpeg (not in PATH?): {e.Message}"); } } static void Finish(bool success, string log, string outputAssetPath, string outputFull, int progressId) { Progress.Remove(progressId); if (!success) { if (File.Exists(outputFull)) File.Delete(outputFull); Debug.LogError($"[PingPong] ffmpeg failed for '{outputAssetPath}':\n{log}"); return; } AssetDatabase.ImportAsset(outputAssetPath); var imported = AssetDatabase.LoadAssetAtPath(outputAssetPath); EditorGUIUtility.PingObject(imported); Debug.Log($"[PingPong] Created '{outputAssetPath}'.", imported); } } }