统一导出类结构

This commit is contained in:
ww-rm
2025-03-25 23:25:04 +08:00
parent 7c4c53dcb0
commit c2cf25bb2b
12 changed files with 579 additions and 469 deletions

View File

@@ -1,9 +1,11 @@
using System;
using SpineViewer.Spine;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing.Design;
using System.Drawing.Imaging;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
@@ -14,6 +16,46 @@ namespace SpineViewer.Exporter
/// </summary>
public abstract class ExportArgs
{
/// <summary>
/// 实现类缓存
/// </summary>
private static readonly Dictionary<ExportType, Type> ImplementationTypes = [];
static ExportArgs()
{
var impTypes = Assembly.GetExecutingAssembly().GetTypes().Where(t => typeof(ExportArgs).IsAssignableFrom(t) && !t.IsAbstract);
foreach (var type in impTypes)
{
var attr = type.GetCustomAttribute<ExportImplementationAttribute>();
if (attr is not null)
{
if (ImplementationTypes.ContainsKey(attr.ExportType))
throw new InvalidOperationException($"Multiple implementations found: {attr.ExportType}");
ImplementationTypes[attr.ExportType] = type;
}
}
Program.Logger.Debug("Find export args implementations: [{}]", string.Join(", ", ImplementationTypes.Keys));
}
/// <summary>
/// 创建指定类型导出参数
/// </summary>
public static ExportArgs New(ExportType exportType, Size resolution, SFML.Graphics.View view, bool renderSelectedOnly)
{
if (!ImplementationTypes.TryGetValue(exportType, out var type))
{
throw new NotImplementedException($"Not implemented type: {exportType}");
}
return (ExportArgs)Activator.CreateInstance(type, resolution, view, renderSelectedOnly);
}
public ExportArgs(Size resolution, SFML.Graphics.View view, bool renderSelectedOnly)
{
Resolution = resolution;
View = view;
RenderSelectedOnly = renderSelectedOnly;
}
/// <summary>
/// 输出文件夹
/// </summary>
@@ -30,24 +72,21 @@ namespace SpineViewer.Exporter
/// <summary>
/// 画面分辨率
/// </summary>
[ReadOnly(true)]
[TypeConverter(typeof(SizeConverter))]
[Category("导出"), DisplayName("分辨率"), Description("画面的宽高像素大小,请在预览画面参数面板进行调整")]
public required Size Resolution { get; init; }
public Size Resolution { get; }
/// <summary>
/// 渲染视窗
/// </summary>
[ReadOnly(true)]
[Category("导出"), DisplayName("视图"), Description("画面的视图参数,请在预览画面参数面板进行调整")]
public required SFML.Graphics.View View { get; init; }
public SFML.Graphics.View View { get; }
/// <summary>
/// 是否仅渲染选中
/// </summary>
[ReadOnly(true)]
[Category("导出"), DisplayName("仅渲染选中"), Description("是否仅导出选中的模型,请在预览画面参数面板进行调整")]
public required bool RenderSelectedOnly { get; init; }
public bool RenderSelectedOnly { get; }
/// <summary>
/// 检查参数是否合法并规范化参数值, 否则返回用户错误原因
@@ -65,109 +104,4 @@ namespace SpineViewer.Exporter
return null;
}
}
/// <summary>
/// 单帧画面导出参数
/// </summary>
public class FrameExportArgs : ExportArgs
{
/// <summary>
/// 单帧画面格式
/// </summary>
[TypeConverter(typeof(ImageFormatConverter))]
[Category("单帧画面"), DisplayName("图像格式")]
public ImageFormat ImageFormat
{
get => imageFormat;
set
{
if (value == ImageFormat.MemoryBmp) value = ImageFormat.Bmp;
imageFormat = value;
}
}
private ImageFormat imageFormat = ImageFormat.Png;
/// <summary>
/// 文件名后缀
/// </summary>
[Category("单帧画面"), DisplayName("文件名后缀"), Description("与图像格式匹配的文件名后缀")]
public string FileSuffix { get => imageFormat.GetSuffix(); }
/// <summary>
/// 四周填充像素值
/// </summary>
[TypeConverter(typeof(PaddingConverter))]
[Category("单帧画面"), DisplayName("四周填充像素值"), Description("在图内四周留出来的透明像素区域, 画面内容的可用范围是分辨率裁去填充区域")]
public Padding Padding
{
get => padding;
set
{
if (value.Left < 0) value.Left = 0;
if (value.Right < 0) value.Right = 0;
if (value.Top < 0) value.Top = 0;
if (value.Bottom < 0) value.Bottom = 0;
padding = value;
}
}
private Padding padding = new(1);
/// <summary>
/// DPI
/// </summary>
[TypeConverter(typeof(SizeFConverter))]
[Category("单帧画面"), DisplayName("DPI"), Description("导出图像的每英寸像素数,用于调整图像的物理尺寸")]
public SizeF DPI
{
get => dpi;
set
{
if (value.Width <= 0) value.Width = 144;
if (value.Height <= 0) value.Height = 144;
dpi = value;
}
}
private SizeF dpi = new(144, 144);
}
/// <summary>
/// 视频导出参数基类
/// </summary>
public abstract class VideoExportArgs : ExportArgs
{
/// <summary>
/// 导出时长
/// </summary>
[Category("视频参数"), DisplayName("时长"), Description("可以从模型列表查看动画时长")]
public float Duration { get => duration; set => duration = Math.Max(0, value); }
private float duration = 1;
/// <summary>
/// 帧率
/// </summary>
[Category("视频参数"), DisplayName("帧率"), Description("每秒画面数")]
public float FPS { get; set; } = 60;
}
/// <summary>
/// 帧序列导出参数
/// </summary>
public class FrameSequenceExportArgs : VideoExportArgs
{
/// <summary>
/// 文件名后缀
/// </summary>
[TypeConverter(typeof(SFMLImageFileSuffixConverter))]
[Category("帧序列参数"), DisplayName("文件名后缀"), Description("帧文件的后缀,同时决定帧图像格式")]
public string FileSuffix { get; set; } = ".png";
}
/// <summary>
/// GIF 导出参数
/// </summary>
public class GifExportArgs : VideoExportArgs
{
}
}

View File

@@ -8,37 +8,91 @@ using System.Threading.Tasks;
namespace SpineViewer.Exporter
{
/// <summary>
/// 导出类型
/// </summary>
public enum ExportType
{
Frame,
FrameSequence,
GIF,
MKV,
MP4,
MOV,
WebM
}
/// <summary>
/// 导出实现类标记
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public class ExportImplementationAttribute : Attribute
{
public ExportType ExportType { get; }
public ExportImplementationAttribute(ExportType exportType)
{
ExportType = exportType;
}
}
/// <summary>
/// SFML.Graphics.Image 帧对象包装类
/// </summary>
public class SFMLImageVideoFrame(SFML.Graphics.Image image) : IVideoFrame, IDisposable
{
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);
public void Dispose() => image.Dispose();
/// <summary>
/// Save the contents of the image to a file
/// </summary>
/// <param name="filename">Path of the file to save (overwritten if already exist)</param>
/// <returns>True if saving was successful</returns>
public bool SaveToFile(string filename) => image.SaveToFile(filename);
/// <summary>
/// Save the image to a buffer in memory The format of the image must be specified.
/// The supported image formats are bmp, png, tga and jpg. This function fails if
/// the image is empty, or if the format was invalid.
/// </summary>
/// <param name="output">Byte array filled with encoded data</param>
/// <param name="format">Encoding format to use</param>
/// <returns>True if saving was successful</returns>
public bool SaveToMemory(out byte[] output, string format) => image.SaveToMemory(out output, format);
/// <summary>
/// 获取 Winforms Bitmap 对象
/// </summary>
public Bitmap CopyToBitmap()
{
image.SaveToMemory(out var imgBuffer, "bmp");
using var stream = new MemoryStream(imgBuffer);
return new(new Bitmap(stream));
}
}
/// <summary>
/// 为帧导出创建的辅助类
/// </summary>
public static class ExportHelper
{
/// <summary>
/// 从纹理对象获取 Winforms Bitmap 对象
/// </summary>
public static Bitmap CopyToBitmap(this SFML.Graphics.Texture tex)
{
using var img = tex.CopyToImage();
img.SaveToMemory(out var imgBuffer, "bmp");
using var stream = new MemoryStream(imgBuffer);
return new Bitmap(stream);
}
/// <summary>
/// 从纹理获取适合 FFMpegCore 的帧对象
/// </summary>
public static SFMLImageVideoFrame CopyToFrame(this SFML.Graphics.Texture tex) => new(tex.CopyToImage());
/// <summary>
/// 根据文件格式获取合适的文件后缀
/// 根据 Bitmap 文件格式获取合适的文件后缀
/// </summary>
public static string GetSuffix(this ImageFormat imageFormat)
{
if (imageFormat == ImageFormat.Icon) return ".ico";
else if (imageFormat == ImageFormat.Exif) return ".jpg";
else if (imageFormat == ImageFormat.Exif) return ".jpeg";
else return $".{imageFormat.ToString().ToLower()}";
}
#region
/// <summary>
/// 获取某个包围盒下合适的视图
/// </summary>
@@ -80,5 +134,7 @@ namespace SpineViewer.Exporter
return new(new(x, y), new(viewX, -viewY));
}
#endregion
}
}

View File

@@ -3,8 +3,10 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using static System.Windows.Forms.VisualStyles.VisualStyleElement.StartPanel;
namespace SpineViewer.Exporter
{
@@ -13,273 +15,110 @@ namespace SpineViewer.Exporter
/// </summary>
public abstract class Exporter
{
/// <summary>
/// 实现类缓存
/// </summary>
private static readonly Dictionary<ExportType, Type> ImplementationTypes = [];
static Exporter()
{
var impTypes = Assembly.GetExecutingAssembly().GetTypes().Where(t => typeof(Exporter).IsAssignableFrom(t) && !t.IsAbstract);
foreach (var type in impTypes)
{
var attr = type.GetCustomAttribute<ExportImplementationAttribute>();
if (attr is not null)
{
if (ImplementationTypes.ContainsKey(attr.ExportType))
throw new InvalidOperationException($"Multiple implementations found: {attr.ExportType}");
ImplementationTypes[attr.ExportType] = type;
}
}
Program.Logger.Debug("Find exporter implementations: [{}]", string.Join(", ", ImplementationTypes.Keys));
}
/// <summary>
/// 创建指定类型导出参数
/// </summary>
public static Exporter New(ExportType exportType, ExportArgs exportArgs)
{
if (!ImplementationTypes.TryGetValue(exportType, out var type))
{
throw new NotImplementedException($"Not implemented type: {exportType}");
}
return (Exporter)Activator.CreateInstance(type, exportArgs);
}
/// <summary>
/// 导出参数
/// </summary>
public required ExportArgs ExportArgs { get; init; }
public ExportArgs ExportArgs { get; }
/// <summary>
/// 根据参数获取渲染目标
/// 渲染目标
/// </summary>
protected SFML.Graphics.RenderTexture GetRenderTexture()
private SFML.Graphics.RenderTexture tex;
/// <summary>
/// 可用于文件名的时间戳字符串
/// </summary>
protected readonly string timestamp;
public Exporter(ExportArgs exportArgs)
{
ExportArgs = exportArgs;
timestamp = DateTime.Now.ToString("yyMMddHHmmss");
}
/// <summary>
/// 获取单个模型的单帧画面
/// </summary>
protected SFMLImageVideoFrame GetFrame(Spine.Spine spine)
{
var tex = new SFML.Graphics.RenderTexture((uint)ExportArgs.Resolution.Width, (uint)ExportArgs.Resolution.Height);
tex.Clear(SFML.Graphics.Color.Transparent);
tex.SetView(ExportArgs.View);
return tex;
tex.Draw(spine);
tex.Display();
return new(tex.Texture.CopyToImage());
}
/// <summary>
/// 得到需要渲染的模型数组,并按渲染顺序排列
/// 获取模型列表的单帧画面
/// </summary>
protected Spine.Spine[] GetSpinesToRender(IEnumerable<Spine.Spine> spines)
protected SFMLImageVideoFrame GetFrame(Spine.Spine[] spinesToRender)
{
return spines.Where(sp => !ExportArgs.RenderSelectedOnly || sp.IsSelected).Reverse().ToArray();
tex.Clear(SFML.Graphics.Color.Transparent);
foreach (var spine in spinesToRender) tex.Draw(spine);
tex.Display();
return new(tex.Texture.CopyToImage());
}
/// <summary>
/// 每个模型在同一个画面进行导出
/// </summary>
protected abstract void ExportSingle(Spine.Spine[] spinesToRender, BackgroundWorker? worker = null);
/// <summary>
/// 每个模型独立导出
/// </summary>
protected abstract void ExportIndividual(Spine.Spine[] spinesToRender, BackgroundWorker? worker = null);
/// <summary>
/// 执行导出
/// </summary>
/// <param name="spines">要进行导出的 Spine 列表</param>
/// <param name="worker">用来执行该函数的 worker</param>
public abstract void Export(IEnumerable<Spine.Spine> spines, BackgroundWorker? worker = null);
}
/// <summary>
/// 单帧画面导出器
/// </summary>
public class FrameExporter : Exporter
{
public override void Export(IEnumerable<Spine.Spine> spines, BackgroundWorker? worker = null)
public virtual void Export(Spine.Spine[] spines, BackgroundWorker? worker = null)
{
var args = (FrameExportArgs)ExportArgs;
using var tex = GetRenderTexture();
var spinesToRender = GetSpinesToRender(spines);
var timestamp = DateTime.Now;
var spinesToRender = spines.Where(sp => !ExportArgs.RenderSelectedOnly || sp.IsSelected).Reverse().ToArray();
int total = spinesToRender.Length;
int success = 0;
int error = 0;
worker?.ReportProgress(0, $"已处理 0/{total}");
for (int i = 0; i < total; i++)
// tex 必须临时创建, 防止出现跨线程的情况
using (tex = new SFML.Graphics.RenderTexture((uint)ExportArgs.Resolution.Width, (uint)ExportArgs.Resolution.Height))
{
if (worker?.CancellationPending == true)
{
Program.Logger.Info("Export cancelled");
break;
}
var spine = spinesToRender[i];
tex.Draw(spine);
if (args.ExportSingle)
{
// 导出单个则直接算成功, 在最后一次将整体导出
success++;
if (i >= total - 1)
{
tex.Display();
// 导出单个时必定提供输出文件夹
var filename = $"frame_{timestamp:yyMMddHHmmss}{args.FileSuffix}";
var savePath = Path.Combine(args.OutputDir, filename);
try
{
using (var img = new Bitmap(tex.Texture.CopyToBitmap()))
{
img.SetResolution(args.DPI.Width, args.DPI.Height);
img.Save(savePath, args.ImageFormat);
}
}
catch (Exception ex)
{
Program.Logger.Error(ex.ToString());
Program.Logger.Error("Failed to save single frame");
}
}
}
else
{
// 逐个导出则立即渲染, 并且保存完之后需要清除画面
tex.Display();
// 逐个导出时如果提供了输出文件夹, 则全部导出到输出文件夹, 否则输出到各自的文件夹
var filename = $"{spine.Name}_{timestamp:yyMMddHHmmss}{args.FileSuffix}";
var savePath = args.OutputDir is null ? Path.Combine(spine.AssetsDir, filename) : Path.Combine(args.OutputDir, filename);
try
{
using (var img = new Bitmap(tex.Texture.CopyToBitmap()))
{
img.SetResolution(args.DPI.Width, args.DPI.Height);
img.Save(savePath, args.ImageFormat);
}
success++;
}
catch (Exception ex)
{
Program.Logger.Error(ex.ToString());
Program.Logger.Error("Failed to save frame {}", spine.SkelPath);
error++;
}
tex.Clear(SFML.Graphics.Color.Transparent);
}
worker?.ReportProgress((int)((i + 1) * 100.0) / total, $"已处理 {i + 1}/{total}");
}
// 输出逐个导出的统计信息
if (!args.ExportSingle)
{
if (error > 0)
Program.Logger.Warn("Frames save {} successfully, {} failed", success, error);
else
Program.Logger.Info("{} frames saved successfully", success);
}
Program.LogCurrentMemoryUsage();
}
}
/// <summary>
/// 视频导出基类
/// </summary>
public abstract class VideoExporter : Exporter
{
/// <summary>
/// 生成单个模型的帧序列
/// </summary>
protected IEnumerable<SFMLImageVideoFrame> GetFrames(SFML.Graphics.RenderTexture tex, Spine.Spine spine, BackgroundWorker? worker = null)
{
var args = (VideoExportArgs)ExportArgs;
float delta = 1f / args.FPS;
int total = 1 + (int)(args.Duration * args.FPS); // 至少导出 1 帧
spine.CurrentAnimation = spine.CurrentAnimation;
worker?.ReportProgress(0, $"{spine.Name} 已处理 0/{total} 帧");
for (int i = 0; i < total; i++)
{
if (worker?.CancellationPending == true)
{
Program.Logger.Info("Export cancelled");
break;
}
tex.Clear(SFML.Graphics.Color.Transparent);
tex.Draw(spine);
spine.Update(delta);
tex.Display();
worker?.ReportProgress((int)((i + 1) * 100.0) / total, $"{spine.Name} 已处理 {i + 1}/{total} 帧");
yield return tex.Texture.CopyToFrame();
}
}
/// <summary>
/// 生成多个模型的帧序列
/// </summary>
protected IEnumerable<SFMLImageVideoFrame> GetFrames(SFML.Graphics.RenderTexture tex, Spine.Spine[] spines, BackgroundWorker? worker = null)
{
var args = (VideoExportArgs)ExportArgs;
float delta = 1f / args.FPS;
int total = 1 + (int)(args.Duration * args.FPS); // 至少导出 1 帧
foreach (var spine in spines) spine.CurrentAnimation = spine.CurrentAnimation;
worker?.ReportProgress(0, $"已处理 0/{total} 帧");
for (int i = 0; i < total; i++)
{
if (worker?.CancellationPending == true)
{
Program.Logger.Info("Export cancelled");
break;
}
tex.Clear(SFML.Graphics.Color.Transparent);
foreach (var spine in spines)
{
tex.Draw(spine);
spine.Update(delta);
}
tex.Display();
worker?.ReportProgress((int)((i + 1) * 100.0) / total, $"已处理 {i + 1}/{total} 帧");
yield return tex.Texture.CopyToFrame();
}
}
}
/// <summary>
/// 帧序列导出器
/// </summary>
public class FrameSequenceExporter : VideoExporter
{
public override void Export(IEnumerable<Spine.Spine> spines, BackgroundWorker? worker = null)
{
var args = (FrameSequenceExportArgs)ExportArgs;
using var tex = GetRenderTexture();
var spinesToRender = GetSpinesToRender(spines);
var timestamp = DateTime.Now;
if (args.ExportSingle)
{
int frameIdx = 0;
foreach (var frame in GetFrames(tex, spinesToRender, worker))
{
// 导出单个时必定提供输出文件夹
var filename = $"frames_{timestamp:yyMMddHHmmss}_{args.FPS:f0}_{frameIdx:d6}{args.FileSuffix}";
var savePath = Path.Combine(args.OutputDir, filename);
try
{
frame.SaveToFile(savePath);
}
catch (Exception ex)
{
Program.Logger.Error(ex.ToString());
Program.Logger.Error("Failed to save frame {}", savePath);
}
finally
{
frame.Dispose();
}
frameIdx++;
}
}
else
{
foreach (var spine in spinesToRender)
{
if (worker?.CancellationPending == true) break; // 取消的日志在 GetFrames 里输出
// 如果提供了输出文件夹, 则全部导出到输出文件夹, 否则导出到各自的文件夹下
var subDir = $"{spine.Name}_{timestamp:yyMMddHHmmss}_{args.FPS:f0}";
var saveDir = args.OutputDir is null ? Path.Combine(spine.AssetsDir, subDir) : Path.Combine(args.OutputDir, subDir);
Directory.CreateDirectory(saveDir);
int frameIdx = 0;
foreach (var frame in GetFrames(tex, spine, worker))
{
var filename = $"{spine.Name}_{timestamp:yyMMddHHmmss}_{args.FPS:f0}_{frameIdx:d6}{args.FileSuffix}";
var savePath = Path.Combine(saveDir, filename);
try
{
frame.SaveToFile(savePath);
}
catch (Exception ex)
{
Program.Logger.Error(ex.ToString());
Program.Logger.Error("Failed to save frame {} {}", savePath, spine.SkelPath);
}
finally
{
frame.Dispose();
}
frameIdx++;
}
}
tex.SetView(ExportArgs.View);
if (ExportArgs.ExportSingle) ExportSingle(spinesToRender, worker);
else ExportIndividual(spinesToRender, worker);
}
tex = null;
Program.LogCurrentMemoryUsage();
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing.Imaging;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewer.Exporter.Implementations.ExportArgs
{
/// <summary>
/// 单帧画面导出参数
/// </summary>
[ExportImplementation(ExportType.Frame)]
public class FrameExportArgs : SpineViewer.Exporter.ExportArgs
{
public FrameExportArgs(Size resolution, SFML.Graphics.View view, bool renderSelectedOnly) : base(resolution, view, renderSelectedOnly) { }
/// <summary>
/// 单帧画面格式
/// </summary>
[TypeConverter(typeof(ImageFormatConverter))]
[Category("单帧画面"), DisplayName("图像格式")]
public ImageFormat ImageFormat
{
get => imageFormat;
set
{
if (value == ImageFormat.MemoryBmp) value = ImageFormat.Bmp;
imageFormat = value;
}
}
private ImageFormat imageFormat = ImageFormat.Png;
/// <summary>
/// 文件名后缀
/// </summary>
[Category("单帧画面"), DisplayName("文件名后缀"), Description("与图像格式匹配的文件名后缀")]
public string FileSuffix { get => imageFormat.GetSuffix(); }
/// <summary>
/// DPI
/// </summary>
[TypeConverter(typeof(SizeFConverter))]
[Category("单帧画面"), DisplayName("DPI"), Description("导出图像的每英寸像素数,用于调整图像的物理尺寸")]
public SizeF DPI
{
get => dpi;
set
{
if (value.Width <= 0) value.Width = 144;
if (value.Height <= 0) value.Height = 144;
dpi = value;
}
}
private SizeF dpi = new(144, 144);
}
}

View File

@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing.Imaging;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewer.Exporter.Implementations.ExportArgs
{
/// <summary>
/// 帧序列导出参数
/// </summary>
[ExportImplementation(ExportType.FrameSequence)]
public class FrameSequenceExportArgs : VideoExportArgs
{
public FrameSequenceExportArgs(Size resolution, SFML.Graphics.View view, bool renderSelectedOnly) : base(resolution, view, renderSelectedOnly) { }
/// <summary>
/// 文件名后缀
/// </summary>
[TypeConverter(typeof(SFMLImageFileSuffixConverter))]
[Category("帧序列参数"), DisplayName("文件名后缀"), Description("帧文件的后缀,同时决定帧图像格式")]
public string FileSuffix { get; set; } = ".png";
}
}

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewer.Exporter.Implementations.ExportArgs
{
/// <summary>
/// 视频导出参数基类
/// </summary>
public abstract class VideoExportArgs : SpineViewer.Exporter.ExportArgs
{
public VideoExportArgs(Size resolution, SFML.Graphics.View view, bool renderSelectedOnly) : base(resolution, view, renderSelectedOnly) { }
/// <summary>
/// 导出时长
/// </summary>
[Category("视频参数"), DisplayName("时长"), Description("可以从模型列表查看动画时长")]
public float Duration { get => duration; set => duration = Math.Max(0, value); }
private float duration = 1;
/// <summary>
/// 帧率
/// </summary>
[Category("视频参数"), DisplayName("帧率"), Description("每秒画面数")]
public float FPS { get; set; } = 60;
}
}

View File

@@ -0,0 +1,86 @@
using SpineViewer.Exporter.Implementations.ExportArgs;
using SpineViewer.Spine;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewer.Exporter.Implementations.Exporter
{
/// <summary>
/// 单帧画面导出器
/// </summary>
[ExportImplementation(ExportType.Frame)]
public class FrameExporter : SpineViewer.Exporter.Exporter
{
public FrameExporter(FrameExportArgs exportArgs) : base(exportArgs) { }
protected override void ExportSingle(Spine.Spine[] spinesToRender, BackgroundWorker? worker = null)
{
var args = (FrameExportArgs)ExportArgs;
// 导出单个时必定提供输出文件夹
var filename = $"frame_{timestamp}{args.FileSuffix}";
var savePath = Path.Combine(args.OutputDir, filename);
worker?.ReportProgress(0, $"已处理 0/1");
try
{
using var frame = GetFrame(spinesToRender);
using var img = frame.CopyToBitmap();
img.SetResolution(args.DPI.Width, args.DPI.Height);
img.Save(savePath, args.ImageFormat);
}
catch (Exception ex)
{
Program.Logger.Error(ex.ToString());
Program.Logger.Error("Failed to save single frame");
}
worker?.ReportProgress(100, $"已处理 1/1");
}
protected override void ExportIndividual(Spine.Spine[] spinesToRender, BackgroundWorker? worker = null)
{
var args = (FrameExportArgs)ExportArgs;
int total = spinesToRender.Length;
int success = 0;
int error = 0;
worker?.ReportProgress(0, $"已处理 0/{total}");
for (int i = 0; i < total; i++)
{
var spine = spinesToRender[i];
// 逐个导出时如果提供了输出文件夹, 则全部导出到输出文件夹, 否则输出到各自的文件夹
var filename = $"{spine.Name}_{timestamp}{args.FileSuffix}";
var savePath = args.OutputDir is null ? Path.Combine(spine.AssetsDir, filename) : Path.Combine(args.OutputDir, filename);
try
{
using var frame = GetFrame(spine);
using var img = frame.CopyToBitmap();
img.SetResolution(args.DPI.Width, args.DPI.Height);
img.Save(savePath, args.ImageFormat);
success++;
}
catch (Exception ex)
{
Program.Logger.Error(ex.ToString());
Program.Logger.Error("Failed to save frame {} {}", savePath, spine.SkelPath);
error++;
}
worker?.ReportProgress((int)((i + 1) * 100.0) / total, $"已处理 {i + 1}/{total}");
}
if (error > 0)
Program.Logger.Warn("Frames save {} successfully, {} failed", success, error);
else
Program.Logger.Info("{} frames saved successfully", success);
}
}
}

View File

@@ -0,0 +1,82 @@
using SpineViewer.Exporter.Implementations.ExportArgs;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewer.Exporter.Implementations.Exporter
{
/// <summary>
/// 帧序列导出器
/// </summary>
[ExportImplementation(ExportType.FrameSequence)]
public class FrameSequenceExporter : VideoExporter
{
public FrameSequenceExporter(FrameSequenceExportArgs exportArgs) : base(exportArgs) { }
protected override void ExportSingle(Spine.Spine[] spinesToRender, BackgroundWorker? worker = null)
{
var args = (FrameSequenceExportArgs)ExportArgs;
int frameIdx = 0;
foreach (var frame in GetFrames(spinesToRender, worker))
{
// 导出单个时必定提供输出文件夹
var filename = $"frames_{timestamp}_{args.FPS:f0}_{frameIdx:d6}{args.FileSuffix}";
var savePath = Path.Combine(args.OutputDir, filename);
try
{
frame.SaveToFile(savePath);
}
catch (Exception ex)
{
Program.Logger.Error(ex.ToString());
Program.Logger.Error("Failed to save frame {}", savePath);
}
finally
{
frame.Dispose();
}
frameIdx++;
}
}
protected override void ExportIndividual(Spine.Spine[] spinesToRender, BackgroundWorker? worker = null)
{
var args = (FrameSequenceExportArgs)ExportArgs;
foreach (var spine in spinesToRender)
{
if (worker?.CancellationPending == true) break; // 取消的日志在 GetFrames 里输出
// 如果提供了输出文件夹, 则全部导出到输出文件夹, 否则导出到各自的文件夹下
var subDir = $"{spine.Name}_{timestamp}_{args.FPS:f0}";
var saveDir = args.OutputDir is null ? Path.Combine(spine.AssetsDir, subDir) : Path.Combine(args.OutputDir, subDir);
Directory.CreateDirectory(saveDir);
int frameIdx = 0;
foreach (var frame in GetFrames(spine, worker))
{
var filename = $"{spine.Name}_{timestamp}_{args.FPS:f0}_{frameIdx:d6}{args.FileSuffix}";
var savePath = Path.Combine(saveDir, filename);
try
{
frame.SaveToFile(savePath);
}
catch (Exception ex)
{
Program.Logger.Error(ex.ToString());
Program.Logger.Error("Failed to save frame {} {}", savePath, spine.SkelPath);
}
finally
{
frame.Dispose();
}
frameIdx++;
}
}
}
}
}

View File

@@ -0,0 +1,76 @@
using SpineViewer.Exporter.Implementations.ExportArgs;
using SpineViewer.Spine;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewer.Exporter.Implementations.Exporter
{
/// <summary>
/// 视频导出基类
/// </summary>
public abstract class VideoExporter : SpineViewer.Exporter.Exporter
{
public VideoExporter(VideoExportArgs exportArgs) : base(exportArgs) { }
/// <summary>
/// 生成单个模型的帧序列
/// </summary>
protected IEnumerable<SFMLImageVideoFrame> GetFrames(Spine.Spine spine, BackgroundWorker? worker = null)
{
var args = (VideoExportArgs)ExportArgs;
float delta = 1f / args.FPS;
int total = 1 + (int)(args.Duration * args.FPS); // 至少导出 1 帧
worker?.ReportProgress(0, $"{spine.Name} 已处理 0/{total} 帧");
for (int i = 0; i < total; i++)
{
if (worker?.CancellationPending == true)
{
Program.Logger.Info("Export cancelled");
break;
}
var frame = GetFrame(spine);
spine.Update(delta);
worker?.ReportProgress((int)((i + 1) * 100.0) / total, $"{spine.Name} 已处理 {i + 1}/{total} 帧");
yield return frame;
}
}
/// <summary>
/// 生成多个模型的帧序列
/// </summary>
protected IEnumerable<SFMLImageVideoFrame> GetFrames(Spine.Spine[] spinesToRender, BackgroundWorker? worker = null)
{
var args = (VideoExportArgs)ExportArgs;
float delta = 1f / args.FPS;
int total = 1 + (int)(args.Duration * args.FPS); // 至少导出 1 帧
worker?.ReportProgress(0, $"已处理 0/{total} 帧");
for (int i = 0; i < total; i++)
{
if (worker?.CancellationPending == true)
{
Program.Logger.Info("Export cancelled");
break;
}
var frame = GetFrame(spinesToRender);
foreach (var spine in spinesToRender) spine.Update(delta);
worker?.ReportProgress((int)((i + 1) * 100.0) / total, $"已处理 {i + 1}/{total} 帧");
yield return frame;
}
}
public override void Export(Spine.Spine[] spines, BackgroundWorker? worker = null)
{
// 导出视频格式需要把模型时间都重置到 0
foreach (var spine in spines) spine.CurrentAnimation = spine.CurrentAnimation;
base.Export(spines, worker);
}
}
}

View File

@@ -1,39 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using FFMpegCore.Pipes;
namespace SpineViewer.Exporter
{
/// <summary>
/// SFML.Graphics.Image 帧对象包装类
/// </summary>
public class SFMLImageVideoFrame(SFML.Graphics.Image image) : IVideoFrame, IDisposable
{
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);
public void Dispose() => image.Dispose();
/// <summary>
/// Save the contents of the image to a file
/// </summary>
/// <param name="filename">Path of the file to save (overwritten if already exist)</param>
/// <returns>True if saving was successful</returns>
public bool SaveToFile(string filename) => image.SaveToFile(filename);
/// <summary>
/// Save the image to a buffer in memory The format of the image must be specified.
/// The supported image formats are bmp, png, tga and jpg. This function fails if
/// the image is empty, or if the format was invalid.
/// </summary>
/// <param name="output">Byte array filled with encoded data</param>
/// <param name="format">Encoding format to use</param>
/// <returns>True if saving was successful</returns>
public bool SaveToMemory(out byte[] output, string format) => image.SaveToMemory(out output, format);
}
}

View File

@@ -142,14 +142,14 @@
toolStripMenuItem_ExportFrame.Name = "toolStripMenuItem_ExportFrame";
toolStripMenuItem_ExportFrame.Size = new Size(270, 34);
toolStripMenuItem_ExportFrame.Text = "单帧画面...";
toolStripMenuItem_ExportFrame.Click += toolStripMenuItem_ExportFrame_Click;
toolStripMenuItem_ExportFrame.Click += toolStripMenuItem_Export_Click;
//
// toolStripMenuItem_ExportFrames
// toolStripMenuItem_ExportFrameSequence
//
toolStripMenuItem_ExportFrameSequence.Name = "toolStripMenuItem_ExportFrames";
toolStripMenuItem_ExportFrameSequence.Name = "toolStripMenuItem_ExportFrameSequence";
toolStripMenuItem_ExportFrameSequence.Size = new Size(270, 34);
toolStripMenuItem_ExportFrameSequence.Text = "帧序列...";
toolStripMenuItem_ExportFrameSequence.Click += toolStripMenuItem_ExportFrameSequence_Click;
toolStripMenuItem_ExportFrameSequence.Click += toolStripMenuItem_Export_Click;
//
// toolStripMenuItem_ExportGif
//

View File

@@ -18,6 +18,10 @@ namespace SpineViewer
{
InitializeComponent();
InitializeLogConfiguration();
// 在此处将导出菜单需要的类绑定起来
toolStripMenuItem_ExportFrame.Tag = ExportType.Frame;
toolStripMenuItem_ExportFrameSequence.Tag = ExportType.FrameSequence;
}
/// <summary>
@@ -68,43 +72,16 @@ namespace SpineViewer
spineListView.BatchAdd();
}
private void toolStripMenuItem_ExportFrame_Click(object sender, EventArgs e)
private void toolStripMenuItem_Export_Click(object sender, EventArgs e)
{
lock (spineListView.Spines)
{
if (spineListView.Spines.Count <= 0)
{
MessageBox.Info("请至少打开一个骨骼文件");
return;
}
}
ExportType type = (ExportType)((ToolStripMenuItem)sender).Tag;
if (spinePreviewer.IsUpdating)
if (type == ExportType.Frame && spinePreviewer.IsUpdating)
{
if (MessageBox.Quest("画面仍在更新,建议手动暂停画面后导出固定的一帧,是否继续?") != DialogResult.OK)
return;
}
var exportDialog = new Dialogs.ExportDialog()
{
ExportArgs = new FrameExportArgs()
{
Resolution = spinePreviewer.Resolution,
View = spinePreviewer.GetView(),
RenderSelectedOnly = spinePreviewer.RenderSelectedOnly,
}
};
if (exportDialog.ShowDialog() != DialogResult.OK)
return;
var progressDialog = new Dialogs.ProgressDialog();
progressDialog.DoWork += ExportFrame_Work;
progressDialog.RunWorkerAsync(exportDialog.ExportArgs);
progressDialog.ShowDialog();
}
private void toolStripMenuItem_ExportFrameSequence_Click(object sender, EventArgs e)
{
lock (spineListView.Spines)
{
if (spineListView.Spines.Count <= 0)
@@ -114,21 +91,16 @@ namespace SpineViewer
}
}
var exportDialog = new Dialogs.ExportDialog()
{
ExportArgs = new FrameSequenceExportArgs()
{
Resolution = spinePreviewer.Resolution,
View = spinePreviewer.GetView(),
RenderSelectedOnly = spinePreviewer.RenderSelectedOnly,
}
};
var exportArgs = ExportArgs.New(type, spinePreviewer.Resolution, spinePreviewer.GetView(), spinePreviewer.RenderSelectedOnly);
var exportDialog = new Dialogs.ExportDialog() { ExportArgs = exportArgs };
if (exportDialog.ShowDialog() != DialogResult.OK)
return;
var exporter = Exporter.Exporter.New(type, exportArgs);
var progressDialog = new Dialogs.ProgressDialog();
progressDialog.DoWork += ExportFrameSequence_Work;
progressDialog.RunWorkerAsync(exportDialog.ExportArgs);
progressDialog.DoWork += Export_Work;
progressDialog.RunWorkerAsync(exporter);
progressDialog.ShowDialog();
}
@@ -236,22 +208,12 @@ namespace SpineViewer
propertyGrid_Spine.Refresh();
}
private void ExportFrame_Work(object? sender, DoWorkEventArgs e)
private void Export_Work(object? sender, DoWorkEventArgs e)
{
var worker = (BackgroundWorker)sender;
var exporter = new FrameExporter() { ExportArgs = (ExportArgs)e.Argument };
var exporter = (Exporter.Exporter)e.Argument;
spinePreviewer.StopRender();
lock (spineListView.Spines) { exporter.Export(spineListView.Spines, (BackgroundWorker)sender); }
e.Cancel = worker.CancellationPending;
spinePreviewer.StartRender();
}
private void ExportFrameSequence_Work(object? sender, DoWorkEventArgs e)
{
var worker = (BackgroundWorker)sender;
var exporter = new FrameSequenceExporter() { ExportArgs = (ExportArgs)e.Argument };
spinePreviewer.StopRender();
lock (spineListView.Spines) { exporter.Export(spineListView.Spines, (BackgroundWorker)sender); }
lock (spineListView.Spines) { exporter.Export(spineListView.Spines.ToArray(), (BackgroundWorker)sender); }
e.Cancel = worker.CancellationPending;
spinePreviewer.StartRender();
}