更换为wpf

This commit is contained in:
ww-rm
2025-05-27 15:55:10 +08:00
parent d0f629d9ba
commit 17257a0ffe
316 changed files with 19206 additions and 76537 deletions

View File

@@ -0,0 +1,198 @@
using NLog;
using SFML.Graphics;
using SFML.System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Spine.Exporters
{
/// <summary>
/// 导出类基类, 提供基本的帧渲染功能
/// </summary>
public abstract class BaseExporter : IDisposable
{
/// <summary>
/// 日志器
/// </summary>
protected static readonly Logger _logger = LogManager.GetCurrentClassLogger();
/// <summary>
/// 用于渲染的画布
/// </summary>
protected RenderTexture _renderTexture;
/// <summary>
/// 初始化导出器
/// </summary>
/// <param name="width">画布宽像素值</param>
/// <param name="height">画布高像素值</param>
public BaseExporter(uint width , uint height)
{
if (width <= 0 || height <= 0)
throw new ArgumentException($"Invalid resolution: {width}, {height}");
_renderTexture = new(width, height);
_renderTexture.SetActive(false);
}
/// <summary>
/// 初始化导出器
/// </summary>
public BaseExporter(Vector2u resolution)
{
if (resolution.X <= 0 || resolution.Y <= 0)
throw new ArgumentException($"Invalid resolution: {resolution}");
_renderTexture = new(resolution.X, resolution.Y);
_renderTexture.SetActive(false);
}
/// <summary>
/// 可选的进度回调函数
/// <list type="number">
/// <item><c>total</c>: 任务总量</item>
/// <item><c>done</c>: 已完成量</item>
/// <item><c>progressText</c>: 需要设置的进度提示文本</item>
/// </list>
/// </summary>
public Action<float, float, string>? ProgressReporter { get => _progressReporter; set => _progressReporter = value; }
protected Action<float, float, string>? _progressReporter;
/// <summary>
/// 背景颜色
/// </summary>
public Color BackgroundColor
{
get => _backgroundColor;
set
{
_backgroundColor = value;
var bcPma = value;
var a = bcPma.A / 255f;
bcPma.R = (byte)(bcPma.R * a);
bcPma.G = (byte)(bcPma.G * a);
bcPma.B = (byte)(bcPma.B * a);
_backgroundColorPma = bcPma;
}
}
protected Color _backgroundColor = Color.Transparent;
/// <summary>
/// 预乘后的背景颜色
/// </summary>
protected Color _backgroundColorPma = Color.Transparent;
/// <summary>
/// 画面分辨率
/// <inheritdoc cref="RenderTexture.Size"/>
/// </summary>
public Vector2u Resolution
{
get => _renderTexture.Size;
set
{
if (value.X <= 0 || value.Y <= 0)
{
_logger.Warn("Omit invalid exporter resolution: {0}", value);
return;
}
if (_renderTexture.Size != value)
{
using var old = _renderTexture;
using var view = old.GetView();
var renderTexture = new RenderTexture(value.X, value.Y);
renderTexture.SetActive(false);
renderTexture.SetView(view);
_renderTexture = renderTexture;
}
}
}
/// <summary>
/// <inheritdoc cref="View.Viewport"/>
/// </summary>
public FloatRect Viewport
{
get { using var view = _renderTexture.GetView(); return view.Viewport; }
set { using var view = _renderTexture.GetView(); view.Viewport = value; _renderTexture.SetView(view); }
}
/// <summary>
/// <inheritdoc cref="View.Center"/>
/// </summary>
public Vector2f Center
{
get { using var view = _renderTexture.GetView(); return view.Center; }
set { using var view = _renderTexture.GetView(); view.Center = value; _renderTexture.SetView(view); }
}
/// <summary>
/// <inheritdoc cref="View.Size"/>
/// </summary>
public Vector2f Size
{
get { using var view = _renderTexture.GetView(); return view.Size; }
set { using var view = _renderTexture.GetView(); view.Size = value; _renderTexture.SetView(view); }
}
/// <summary>
/// <inheritdoc cref="View.Rotation"/>
/// </summary>
public float Rotation
{
get { using var view = _renderTexture.GetView(); return view.Rotation; }
set { using var view = _renderTexture.GetView(); view.Rotation = value; _renderTexture.SetView(view); }
}
/// <summary>
/// 获取的一帧, 结果是预乘的
/// </summary>
protected virtual SFMLImageVideoFrame GetFrame(SpineObject[] spines)
{
_renderTexture.SetActive(true);
_renderTexture.Clear(_backgroundColorPma);
foreach (var sp in spines.Reverse()) _renderTexture.Draw(sp);
_renderTexture.Display();
_renderTexture.SetActive(false);
return new(_renderTexture.Texture.CopyToImage());
}
/// <summary>
/// 导出给定的模型, 从前往后对应从上往下的渲染顺序
/// </summary>
/// <param name="output">输出路径, 一般而言都是文件路径, 少数情况指定的是文件夹</param>
/// <param name="spines">要导出的模型, 从前往后对应从上往下的渲染顺序</param>
public abstract void Export(string output, params SpineObject[] spines);
#region IDisposable
private bool _disposed = false;
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
_renderTexture.Dispose();
}
_disposed = true;
}
~BaseExporter()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
if (_disposed)
{
GC.SuppressFinalize(this);
}
}
#endregion
}
}

View File

@@ -0,0 +1,82 @@
using FFMpegCore;
using FFMpegCore.Pipes;
using SFML.System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Spine.Exporters
{
/// <summary>
/// 自定义参数的 FFmpeg 导出类
/// </summary>
public class CustomFFmpegExporter : VideoExporter
{
public CustomFFmpegExporter(uint width = 100, uint height = 100) : base(width, height) { }
public CustomFFmpegExporter(Vector2u resolution) : base(resolution) { }
/// <summary>
/// <c>-f</c>
/// </summary>
public string? Format { get => _format; set => _format = value; }
private string? _format;
/// <summary>
/// <c>-c:v</c>
/// </summary>
public string? Codec { get => _codec; set => _codec = value; }
private string? _codec;
/// <summary>
/// <c>-pix_fmt</c>
/// </summary>
public string? PixelFormat { get => _pixelFormat; set => _pixelFormat = value; }
private string? _pixelFormat;
/// <summary>
/// <c>-b:v</c>
/// </summary>
public string? Bitrate { get => _bitrate; set => _bitrate = value; }
private string? _bitrate;
/// <summary>
/// <c>-vf</c>
/// </summary>
public string? Filter { get => _filter; set => _filter = value; }
private string? _filter;
/// <summary>
/// 其他自定义参数
/// </summary>
public string? CustomArgs { get => _customArgs; set => _customArgs = value; }
private string? _customArgs;
private void SetOutputOptions(FFMpegArgumentOptions options)
{
if (!string.IsNullOrEmpty(_format)) options.ForceFormat(_format);
if (!string.IsNullOrEmpty(_codec)) options.WithVideoCodec(_codec);
if (!string.IsNullOrEmpty(_pixelFormat)) options.ForcePixelFormat(_pixelFormat);
if (!string.IsNullOrEmpty(_bitrate)) options.WithCustomArgument($"-b:v {_bitrate}");
if (!string.IsNullOrEmpty(_filter)) options.WithCustomArgument($"-vf unpremultiply=inplace=1, {_customArgs}");
else options.WithCustomArgument("-vf unpremultiply=inplace=1");
}
public override void Export(string output, CancellationToken ct, params SpineObject[] spines)
{
var videoFramesSource = new RawVideoPipeSource(GetFrames(spines, output, ct)) { FrameRate = _fps };
try
{
var ffmpegArgs = FFMpegArguments.FromPipeInput(videoFramesSource).OutputToFile(output, true, SetOutputOptions);
_logger.Info("FFmpeg arguments: {0}", ffmpegArgs.Arguments);
ffmpegArgs.ProcessSynchronously();
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to export {0} {1}, {2}", _format, output, ex.Message);
}
}
}
}

View File

@@ -0,0 +1,146 @@
using FFMpegCore;
using FFMpegCore.Enums;
using FFMpegCore.Pipes;
using NLog;
using SFML.Graphics;
using SFML.System;
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Spine.Exporters
{
/// <summary>
/// 基于 FFmpeg 命令行的导出类, 可以导出动图及视频格式
/// </summary>
public class FFmpegVideoExporter : VideoExporter
{
public FFmpegVideoExporter(uint width = 100, uint height = 100) : base(width, height) { }
public FFmpegVideoExporter(Vector2u resolution) : base(resolution) { }
/// <summary>
/// FFmpeg 导出格式
/// </summary>
public enum VideoFormat
{
Gif,
Webp,
Mp4,
Webm,
Mkv,
}
/// <summary>
/// 视频格式
/// </summary>
public VideoFormat Format { get => _format; set => _format = value; }
private VideoFormat _format = VideoFormat.Mp4;
/// <summary>
/// 动图是否循环
/// </summary>
public bool Loop { get => _loop; set => _loop = value; }
private bool _loop = true;
/// <summary>
/// 质量
/// </summary>
public int Quality { get => _quality; set => _quality = Math.Clamp(value, 0, 100); }
private int _quality = 75;
/// <summary>
/// CRF
/// </summary>
public int Crf { get => _crf; set => _crf = Math.Clamp(value, 0, 63); }
private int _crf = 23;
/// <summary>
/// 获取的一帧, 结果是预乘的
/// </summary>
protected override SFMLImageVideoFrame GetFrame(SpineObject[] spines)
{
// XXX: 不知道为什么用 FFmpeg 必须临时创建 RenderTexture, 否则无法正常渲染
using var tex = new RenderTexture(_renderTexture.Size.X, _renderTexture.Size.Y);
using var view = _renderTexture.GetView();
tex.SetView(view);
tex.Clear(_backgroundColorPma);
foreach (var sp in spines.Reverse()) tex.Draw(sp);
tex.Display();
return new(tex.Texture.CopyToImage());
}
public override void Export(string output, CancellationToken ct, params SpineObject[] spines)
{
var videoFramesSource = new RawVideoPipeSource(GetFrames(spines, output, ct)) { FrameRate = _fps };
Action<FFMpegArgumentOptions> setOutputOptions = _format switch
{
VideoFormat.Gif => SetGifOptions,
VideoFormat.Webp => SetWebpOptions,
VideoFormat.Mp4 => SetMp4Options,
VideoFormat.Webm => SetWebmOptions,
VideoFormat.Mkv => SetMkvOptions,
_ => throw new NotImplementedException(),
};
try
{
var ffmpegArgs = FFMpegArguments.FromPipeInput(videoFramesSource).OutputToFile(output, true, setOutputOptions);
_logger.Info("FFmpeg arguments: {0}", ffmpegArgs.Arguments);
ffmpegArgs.ProcessSynchronously();
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to export {0} {1}, {2}", _format, output, ex.Message);
}
}
private void SetGifOptions(FFMpegArgumentOptions options)
{
// Gif 固定使用 256 调色板和 128 透明度阈值
var v = "split [s0][s1]";
var s0 = "[s0] palettegen=reserve_transparent=1:max_colors=256 [p]";
var s1 = "[s1][p] paletteuse=dither=bayer:alpha_threshold=128";
var customArgs = $"-vf \"unpremultiply=inplace=1, {v};{s0};{s1}\" -loop {(_loop ? 0 : -1)}";
options.ForceFormat("gif")
.WithCustomArgument(customArgs);
}
private void SetWebpOptions(FFMpegArgumentOptions options)
{
var customArgs = $"-vf unpremultiply=inplace=1 -quality {_quality} -loop {(_loop ? 0 : 1)}";
options.ForceFormat("webp").WithVideoCodec("libwebp_anim").ForcePixelFormat("yuva420p")
.WithCustomArgument(customArgs);
}
private void SetMp4Options(FFMpegArgumentOptions options)
{
var customArgs = "-vf unpremultiply=inplace=1";
options.ForceFormat("mp4").WithVideoCodec("libx264").ForcePixelFormat("yuv444p")
.WithFastStart()
.WithConstantRateFactor(_crf)
.WithCustomArgument(customArgs);
}
private void SetWebmOptions(FFMpegArgumentOptions options)
{
var customArgs = "-vf unpremultiply=inplace=1";
options.ForceFormat("webm").WithVideoCodec("libvpx-vp9").ForcePixelFormat("yuva420p")
.WithConstantRateFactor(_crf)
.WithCustomArgument(customArgs);
}
private void SetMkvOptions(FFMpegArgumentOptions options)
{
var customArgs = "-vf unpremultiply=inplace=1";
options.ForceFormat("matroska").WithVideoCodec("libx265").ForcePixelFormat("yuv444p")
.WithConstantRateFactor(_crf)
.WithCustomArgument(customArgs);
}
}
}

View File

@@ -0,0 +1,37 @@
using SFML.System;
using SkiaSharp;
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Spine.Exporters
{
/// <summary>
/// 单帧画面导出类
/// </summary>
public class FrameExporter : BaseExporter
{
public FrameExporter(uint width = 100, uint height = 100) : base(width, height) { }
public FrameExporter(Vector2u resolution) : base(resolution) { }
public SKEncodedImageFormat Format { get => _format; set => _format = value; }
protected SKEncodedImageFormat _format = SKEncodedImageFormat.Png;
public int Quality { get => _quality; set => _quality = Math.Clamp(value, 0, 100); }
protected int _quality = 80;
public override void Export(string output, params SpineObject[] spines)
{
using var frame = GetFrame(spines);
var info = new SKImageInfo(frame.Width, frame.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
using var skImage = SKImage.FromPixelCopy(info, frame.Image.Pixels);
using var data = skImage.Encode(_format, _quality);
using var stream = File.OpenWrite(output);
data.SaveTo(stream);
}
}
}

View File

@@ -0,0 +1,61 @@
using NLog;
using SFML.System;
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Spine.Exporters
{
/// <summary>
/// 帧序列导出器, 导出 png 帧序列
/// </summary>
public class FrameSequenceExporter : VideoExporter
{
public FrameSequenceExporter(uint width = 100, uint height = 100) : base(width, height) { }
public FrameSequenceExporter(Vector2u resolution) : base(resolution) { }
public override void Export(string output, CancellationToken ct, params SpineObject[] spines)
{
Directory.CreateDirectory(output);
int frameCount = GetFrameCount();
int frameIdx = 0;
_progressReporter?.Invoke(frameCount, 0, $"[{frameIdx}/{frameCount}] {output}");
foreach (var frame in GetFrames(spines))
{
if (ct.IsCancellationRequested)
{
_logger.Info("Export cancelled");
frame.Dispose();
break;
}
var savePath = Path.Combine(output, $"frame_{_fps}_{frameIdx:d6}.png");
var info = new SKImageInfo(frame.Width, frame.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
_progressReporter?.Invoke(frameCount, frameIdx, $"[{frameIdx + 1}/{frameCount}] {savePath}");
try
{
using var skImage = SKImage.FromPixelCopy(info, frame.Image.Pixels);
using var data = skImage.Encode(SKEncodedImageFormat.Png, 100);
using var stream = File.OpenWrite(savePath);
data.SaveTo(stream);
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to save frame {0}, {1}", savePath, ex.Message);
}
finally
{
frame.Dispose();
}
frameIdx++;
}
}
}
}

View File

@@ -0,0 +1,52 @@
using FFMpegCore.Pipes;
using SFML.Graphics;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Spine.Exporters
{
/// <summary>
/// <see cref="SFML.Graphics.Image"/> 帧对象包装类, 将接管给定对象生命周期
/// </summary>
public class SFMLImageVideoFrame(Image image) : IVideoFrame, IDisposable
{
private readonly Image _image = image;
/// <summary>
/// 接管的 <see cref="SFML.Graphics.Image"/> 内部对象
/// </summary>
public Image Image => _image;
public int Width => (int)_image.Size.X;
public int Height => (int)_image.Size.Y;
public string Format => "rgba";
public void Serialize(Stream pipe) => pipe.Write(_image.Pixels);
public async Task SerializeAsync(Stream pipe, CancellationToken token) => await pipe.WriteAsync(_image.Pixels, token);
#region IDisposable
private bool _disposed = false;
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_image.Dispose();
}
_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
#endregion
}
}

View File

@@ -0,0 +1,142 @@
using NLog;
using SFML.System;
using SkiaSharp;
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Spine.Exporters
{
/// <summary>
/// 多帧画面导出基类, 可以获取连续的帧序列
/// </summary>
public abstract class VideoExporter : BaseExporter
{
public VideoExporter(uint width, uint height) : base(width, height) { }
public VideoExporter(Vector2u resolution) : base(resolution) { }
/// <summary>
/// 导出时长
/// </summary>
public float Duration
{
get => _duration;
set
{
if (value < 0)
{
_logger.Warn("Omit invalid duration: {0}", value);
return;
}
_duration = value;
}
}
protected float _duration = 0;
/// <summary>
/// 帧率
/// </summary>
public float Fps
{
get => _fps;
set
{
if (value <= 0)
{
_logger.Warn("Omit invalid fps: {0}", value);
return;
}
_fps = value;
}
}
protected float _fps = 24;
/// <summary>
/// 是否保留最后一帧
/// </summary>
public bool KeepLast { get => _keepLast; set => _keepLast = value; }
protected bool _keepLast = true;
/// <summary>
/// 获取总帧数
/// </summary>
public int GetFrameCount()
{
var delta = 1f / _fps;
var total = (int)(_duration * _fps); // 完整帧的数量
var deltaFinal = _duration - delta * total; // 最后一帧时长
var final = _keepLast && deltaFinal > 1e-3 ? 1 : 0;
var frameCount = 1 + total + final; // 所有帧的数量 = 起始帧 + 完整帧 + 最后一帧
return frameCount;
}
/// <summary>
/// 生成帧序列
/// </summary>
protected IEnumerable<SFMLImageVideoFrame> GetFrames(SpineObject[] spines)
{
float delta = 1f / _fps;
int total = (int)(_duration * _fps); // 完整帧的数量
bool hasFinal = _keepLast && (_duration - delta * total) > 1e-3;
// 导出首帧
var firstFrame = GetFrame(spines);
yield return firstFrame;
// 导出完整帧
for (int i = 0; i < total; i++)
{
foreach (var spine in spines) spine.Update(delta);
yield return GetFrame(spines);
}
// 导出最后一帧
if (hasFinal)
{
// XXX: 此处还是按照完整的一帧时长进行更新, 也许可以只更新准确的最后一帧时长
foreach (var spine in spines) spine.Update(delta);
yield return GetFrame(spines);
}
}
/// <summary>
/// 生成帧序列, 支持中途取消和进度输出
/// </summary>
protected IEnumerable<SFMLImageVideoFrame> GetFrames(SpineObject[] spines, string output, CancellationToken ct)
{
int frameCount = GetFrameCount();
int frameIdx = 0;
_progressReporter?.Invoke(frameCount, 0, $"[{frameIdx}/{frameCount}] {output}");
foreach (var frame in GetFrames(spines))
{
if (ct.IsCancellationRequested)
{
_logger.Info("Export cancelled");
frame.Dispose();
break;
}
_progressReporter?.Invoke(frameCount, frameIdx, $"[{frameIdx + 1}/{frameCount}] {output}");
yield return frame;
frameIdx++;
}
}
public sealed override void Export(string output, params SpineObject[] spines) => Export(output, default, spines);
/// <summary>
/// 导出给定的模型, 从前往后对应从上往下的渲染顺序
/// </summary>
/// <param name="output">输出路径, 一般而言都是文件路径, 少数情况指定的是文件夹</param>
/// <param name="ct">取消令牌</param>
/// <param name="spines">要导出的模型, 从前往后对应从上往下的渲染顺序</param>
public abstract void Export(string output, CancellationToken ct, params SpineObject[] spines);
}
}