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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user