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