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>
129 lines
5.0 KiB
C#
129 lines
5.0 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|