From ff01e07fa1ddd2df446d7ca6ee361f58d1aacf2b Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Thu, 9 Jul 2026 16:09:34 +0200 Subject: [PATCH] Add editor tool to create ping-pong videos from VideoClips via ffmpeg Right-click a VideoClip -> Process -> Ping-Pong Video: runs ffmpeg in the background (forward + reversed video concat, audio played twice), writes _pingpong.mp4 next to the source and imports it. Supports multi-select, silent clips (video-only retry), progress display and unique output paths. Co-Authored-By: Claude Fable 5 --- .../Scripts/Editor/VideoPingPongProcessor.cs | 128 ++++++++++++++++++ .../Editor/VideoPingPongProcessor.cs.meta | 2 + 2 files changed, 130 insertions(+) create mode 100644 Assets/Scripts/Editor/VideoPingPongProcessor.cs create mode 100644 Assets/Scripts/Editor/VideoPingPongProcessor.cs.meta diff --git a/Assets/Scripts/Editor/VideoPingPongProcessor.cs b/Assets/Scripts/Editor/VideoPingPongProcessor.cs new file mode 100644 index 0000000..0dedb7c --- /dev/null +++ b/Assets/Scripts/Editor/VideoPingPongProcessor.cs @@ -0,0 +1,128 @@ +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); + } + } +} diff --git a/Assets/Scripts/Editor/VideoPingPongProcessor.cs.meta b/Assets/Scripts/Editor/VideoPingPongProcessor.cs.meta new file mode 100644 index 0000000..2102200 --- /dev/null +++ b/Assets/Scripts/Editor/VideoPingPongProcessor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2e92ee9044ad6424c94803f303508661 \ No newline at end of file