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
<name>_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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-09 16:09:34 +02:00
1 parent 2f38cb384e
commit ff01e07fa1
2 files changed
+130

No files matched your search

@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<VideoClip>(SelectionMode.Assets).Length > 0;
[MenuItem(MenuPath, false, 2000)]
static void Process()
{
var mainThread = SynchronizationContext.Current;
foreach (var clip in Selection.GetFiltered<VideoClip>(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<bool, string> 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<VideoClip>(outputAssetPath);
EditorGUIUtility.PingObject(imported);
Debug.Log($"[PingPong] Created '{outputAssetPath}'.", imported);
}
}
}