diff --git a/SpineViewer/Exporter/ExportArgs.cs b/SpineViewer/Exporter/ExportArgs.cs
index cab876e..61f6043 100644
--- a/SpineViewer/Exporter/ExportArgs.cs
+++ b/SpineViewer/Exporter/ExportArgs.cs
@@ -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
///
public abstract class ExportArgs
{
+ ///
+ /// 实现类缓存
+ ///
+ private static readonly Dictionary 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();
+ 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));
+ }
+
+ ///
+ /// 创建指定类型导出参数
+ ///
+ 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;
+ }
+
///
/// 输出文件夹
///
@@ -30,24 +72,21 @@ namespace SpineViewer.Exporter
///
/// 画面分辨率
///
- [ReadOnly(true)]
[TypeConverter(typeof(SizeConverter))]
[Category("导出"), DisplayName("分辨率"), Description("画面的宽高像素大小,请在预览画面参数面板进行调整")]
- public required Size Resolution { get; init; }
+ public Size Resolution { get; }
///
/// 渲染视窗
///
- [ReadOnly(true)]
[Category("导出"), DisplayName("视图"), Description("画面的视图参数,请在预览画面参数面板进行调整")]
- public required SFML.Graphics.View View { get; init; }
+ public SFML.Graphics.View View { get; }
///
/// 是否仅渲染选中
///
- [ReadOnly(true)]
[Category("导出"), DisplayName("仅渲染选中"), Description("是否仅导出选中的模型,请在预览画面参数面板进行调整")]
- public required bool RenderSelectedOnly { get; init; }
+ public bool RenderSelectedOnly { get; }
///
/// 检查参数是否合法并规范化参数值, 否则返回用户错误原因
@@ -65,109 +104,4 @@ namespace SpineViewer.Exporter
return null;
}
}
-
- ///
- /// 单帧画面导出参数
- ///
- public class FrameExportArgs : ExportArgs
- {
- ///
- /// 单帧画面格式
- ///
- [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;
-
- ///
- /// 文件名后缀
- ///
- [Category("单帧画面"), DisplayName("文件名后缀"), Description("与图像格式匹配的文件名后缀")]
- public string FileSuffix { get => imageFormat.GetSuffix(); }
-
- ///
- /// 四周填充像素值
- ///
- [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);
-
- ///
- /// DPI
- ///
- [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);
- }
-
- ///
- /// 视频导出参数基类
- ///
- public abstract class VideoExportArgs : ExportArgs
- {
- ///
- /// 导出时长
- ///
- [Category("视频参数"), DisplayName("时长"), Description("可以从模型列表查看动画时长")]
- public float Duration { get => duration; set => duration = Math.Max(0, value); }
- private float duration = 1;
-
- ///
- /// 帧率
- ///
- [Category("视频参数"), DisplayName("帧率"), Description("每秒画面数")]
- public float FPS { get; set; } = 60;
- }
-
- ///
- /// 帧序列导出参数
- ///
- public class FrameSequenceExportArgs : VideoExportArgs
- {
- ///
- /// 文件名后缀
- ///
- [TypeConverter(typeof(SFMLImageFileSuffixConverter))]
- [Category("帧序列参数"), DisplayName("文件名后缀"), Description("帧文件的后缀,同时决定帧图像格式")]
- public string FileSuffix { get; set; } = ".png";
-
- }
-
- ///
- /// GIF 导出参数
- ///
- public class GifExportArgs : VideoExportArgs
- {
-
- }
}
diff --git a/SpineViewer/Exporter/ExportHelper.cs b/SpineViewer/Exporter/ExportHelper.cs
index a5d0265..fa6fced 100644
--- a/SpineViewer/Exporter/ExportHelper.cs
+++ b/SpineViewer/Exporter/ExportHelper.cs
@@ -8,37 +8,91 @@ using System.Threading.Tasks;
namespace SpineViewer.Exporter
{
+ ///
+ /// 导出类型
+ ///
+ public enum ExportType
+ {
+ Frame,
+ FrameSequence,
+ GIF,
+ MKV,
+ MP4,
+ MOV,
+ WebM
+ }
+
+ ///
+ /// 导出实现类标记
+ ///
+ [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
+ public class ExportImplementationAttribute : Attribute
+ {
+ public ExportType ExportType { get; }
+
+ public ExportImplementationAttribute(ExportType exportType)
+ {
+ ExportType = exportType;
+ }
+ }
+
+ ///
+ /// SFML.Graphics.Image 帧对象包装类
+ ///
+ 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();
+
+ ///
+ /// Save the contents of the image to a file
+ ///
+ /// Path of the file to save (overwritten if already exist)
+ /// True if saving was successful
+ public bool SaveToFile(string filename) => image.SaveToFile(filename);
+
+ ///
+ /// 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.
+ ///
+ /// Byte array filled with encoded data
+ /// Encoding format to use
+ /// True if saving was successful
+ public bool SaveToMemory(out byte[] output, string format) => image.SaveToMemory(out output, format);
+
+ ///
+ /// 获取 Winforms Bitmap 对象
+ ///
+ public Bitmap CopyToBitmap()
+ {
+ image.SaveToMemory(out var imgBuffer, "bmp");
+ using var stream = new MemoryStream(imgBuffer);
+ return new(new Bitmap(stream));
+ }
+ }
+
///
/// 为帧导出创建的辅助类
///
public static class ExportHelper
{
///
- /// 从纹理对象获取 Winforms Bitmap 对象
- ///
- 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);
- }
-
- ///
- /// 从纹理获取适合 FFMpegCore 的帧对象
- ///
- public static SFMLImageVideoFrame CopyToFrame(this SFML.Graphics.Texture tex) => new(tex.CopyToImage());
-
- ///
- /// 根据文件格式获取合适的文件后缀
+ /// 根据 Bitmap 文件格式获取合适的文件后缀
///
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 包围盒辅助函数
+
///
/// 获取某个包围盒下合适的视图
///
@@ -80,5 +134,7 @@ namespace SpineViewer.Exporter
return new(new(x, y), new(viewX, -viewY));
}
+
+ #endregion
}
}
diff --git a/SpineViewer/Exporter/Exporter.cs b/SpineViewer/Exporter/Exporter.cs
index 54ece65..a03f412 100644
--- a/SpineViewer/Exporter/Exporter.cs
+++ b/SpineViewer/Exporter/Exporter.cs
@@ -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
///
public abstract class Exporter
{
+ ///
+ /// 实现类缓存
+ ///
+ private static readonly Dictionary 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();
+ 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));
+ }
+
+ ///
+ /// 创建指定类型导出参数
+ ///
+ 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);
+ }
+
///
/// 导出参数
///
- public required ExportArgs ExportArgs { get; init; }
+ public ExportArgs ExportArgs { get; }
///
- /// 根据参数获取渲染目标
+ /// 渲染目标
///
- protected SFML.Graphics.RenderTexture GetRenderTexture()
+ private SFML.Graphics.RenderTexture tex;
+
+ ///
+ /// 可用于文件名的时间戳字符串
+ ///
+ protected readonly string timestamp;
+
+ public Exporter(ExportArgs exportArgs)
+ {
+ ExportArgs = exportArgs;
+ timestamp = DateTime.Now.ToString("yyMMddHHmmss");
+ }
+
+ ///
+ /// 获取单个模型的单帧画面
+ ///
+ 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());
}
///
- /// 得到需要渲染的模型数组,并按渲染顺序排列
+ /// 获取模型列表的单帧画面
///
- protected Spine.Spine[] GetSpinesToRender(IEnumerable 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());
}
+ ///
+ /// 每个模型在同一个画面进行导出
+ ///
+ protected abstract void ExportSingle(Spine.Spine[] spinesToRender, BackgroundWorker? worker = null);
+
+ ///
+ /// 每个模型独立导出
+ ///
+ protected abstract void ExportIndividual(Spine.Spine[] spinesToRender, BackgroundWorker? worker = null);
+
///
/// 执行导出
///
/// 要进行导出的 Spine 列表
/// 用来执行该函数的 worker
- public abstract void Export(IEnumerable spines, BackgroundWorker? worker = null);
- }
-
- ///
- /// 单帧画面导出器
- ///
- public class FrameExporter : Exporter
- {
- public override void Export(IEnumerable 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();
- }
- }
-
- ///
- /// 视频导出基类
- ///
- public abstract class VideoExporter : Exporter
- {
- ///
- /// 生成单个模型的帧序列
- ///
- protected IEnumerable 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();
- }
- }
-
- ///
- /// 生成多个模型的帧序列
- ///
- protected IEnumerable 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();
- }
- }
- }
-
- ///
- /// 帧序列导出器
- ///
- public class FrameSequenceExporter : VideoExporter
- {
- public override void Export(IEnumerable 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();
}
diff --git a/SpineViewer/Exporter/Implementations/ExportArgs/FrameExportArgs.cs b/SpineViewer/Exporter/Implementations/ExportArgs/FrameExportArgs.cs
new file mode 100644
index 0000000..7835bc2
--- /dev/null
+++ b/SpineViewer/Exporter/Implementations/ExportArgs/FrameExportArgs.cs
@@ -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
+{
+ ///
+ /// 单帧画面导出参数
+ ///
+ [ExportImplementation(ExportType.Frame)]
+ public class FrameExportArgs : SpineViewer.Exporter.ExportArgs
+ {
+ public FrameExportArgs(Size resolution, SFML.Graphics.View view, bool renderSelectedOnly) : base(resolution, view, renderSelectedOnly) { }
+
+ ///
+ /// 单帧画面格式
+ ///
+ [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;
+
+ ///
+ /// 文件名后缀
+ ///
+ [Category("单帧画面"), DisplayName("文件名后缀"), Description("与图像格式匹配的文件名后缀")]
+ public string FileSuffix { get => imageFormat.GetSuffix(); }
+
+ ///
+ /// DPI
+ ///
+ [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);
+ }
+}
diff --git a/SpineViewer/Exporter/Implementations/ExportArgs/FrameSequenceExportArgs.cs b/SpineViewer/Exporter/Implementations/ExportArgs/FrameSequenceExportArgs.cs
new file mode 100644
index 0000000..5da5d86
--- /dev/null
+++ b/SpineViewer/Exporter/Implementations/ExportArgs/FrameSequenceExportArgs.cs
@@ -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
+{
+ ///
+ /// 帧序列导出参数
+ ///
+ [ExportImplementation(ExportType.FrameSequence)]
+ public class FrameSequenceExportArgs : VideoExportArgs
+ {
+ public FrameSequenceExportArgs(Size resolution, SFML.Graphics.View view, bool renderSelectedOnly) : base(resolution, view, renderSelectedOnly) { }
+
+ ///
+ /// 文件名后缀
+ ///
+ [TypeConverter(typeof(SFMLImageFileSuffixConverter))]
+ [Category("帧序列参数"), DisplayName("文件名后缀"), Description("帧文件的后缀,同时决定帧图像格式")]
+ public string FileSuffix { get; set; } = ".png";
+ }
+}
diff --git a/SpineViewer/Exporter/Implementations/ExportArgs/VideoExportArgs.cs b/SpineViewer/Exporter/Implementations/ExportArgs/VideoExportArgs.cs
new file mode 100644
index 0000000..dc6aa49
--- /dev/null
+++ b/SpineViewer/Exporter/Implementations/ExportArgs/VideoExportArgs.cs
@@ -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
+{
+ ///
+ /// 视频导出参数基类
+ ///
+ public abstract class VideoExportArgs : SpineViewer.Exporter.ExportArgs
+ {
+ public VideoExportArgs(Size resolution, SFML.Graphics.View view, bool renderSelectedOnly) : base(resolution, view, renderSelectedOnly) { }
+
+ ///
+ /// 导出时长
+ ///
+ [Category("视频参数"), DisplayName("时长"), Description("可以从模型列表查看动画时长")]
+ public float Duration { get => duration; set => duration = Math.Max(0, value); }
+ private float duration = 1;
+
+ ///
+ /// 帧率
+ ///
+ [Category("视频参数"), DisplayName("帧率"), Description("每秒画面数")]
+ public float FPS { get; set; } = 60;
+ }
+}
diff --git a/SpineViewer/Exporter/Implementations/Exporter/FrameExporter.cs b/SpineViewer/Exporter/Implementations/Exporter/FrameExporter.cs
new file mode 100644
index 0000000..5df4f3d
--- /dev/null
+++ b/SpineViewer/Exporter/Implementations/Exporter/FrameExporter.cs
@@ -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
+{
+ ///
+ /// 单帧画面导出器
+ ///
+ [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);
+ }
+ }
+
+}
diff --git a/SpineViewer/Exporter/Implementations/Exporter/FrameSequenceExporter.cs b/SpineViewer/Exporter/Implementations/Exporter/FrameSequenceExporter.cs
new file mode 100644
index 0000000..f7c6453
--- /dev/null
+++ b/SpineViewer/Exporter/Implementations/Exporter/FrameSequenceExporter.cs
@@ -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
+{
+ ///
+ /// 帧序列导出器
+ ///
+ [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++;
+ }
+ }
+ }
+ }
+}
diff --git a/SpineViewer/Exporter/Implementations/Exporter/VideoExporter.cs b/SpineViewer/Exporter/Implementations/Exporter/VideoExporter.cs
new file mode 100644
index 0000000..1f730fc
--- /dev/null
+++ b/SpineViewer/Exporter/Implementations/Exporter/VideoExporter.cs
@@ -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
+{
+ ///
+ /// 视频导出基类
+ ///
+ public abstract class VideoExporter : SpineViewer.Exporter.Exporter
+ {
+ public VideoExporter(VideoExportArgs exportArgs) : base(exportArgs) { }
+
+ ///
+ /// 生成单个模型的帧序列
+ ///
+ protected IEnumerable 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;
+ }
+ }
+
+ ///
+ /// 生成多个模型的帧序列
+ ///
+ protected IEnumerable 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);
+ }
+ }
+}
diff --git a/SpineViewer/Exporter/SFMLImageVideoFrame.cs b/SpineViewer/Exporter/SFMLImageVideoFrame.cs
deleted file mode 100644
index 44a5635..0000000
--- a/SpineViewer/Exporter/SFMLImageVideoFrame.cs
+++ /dev/null
@@ -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
-{
- ///
- /// SFML.Graphics.Image 帧对象包装类
- ///
- 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();
-
- ///
- /// Save the contents of the image to a file
- ///
- /// Path of the file to save (overwritten if already exist)
- /// True if saving was successful
- public bool SaveToFile(string filename) => image.SaveToFile(filename);
-
- ///
- /// 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.
- ///
- /// Byte array filled with encoded data
- /// Encoding format to use
- /// True if saving was successful
- public bool SaveToMemory(out byte[] output, string format) => image.SaveToMemory(out output, format);
- }
-}
diff --git a/SpineViewer/MainForm.Designer.cs b/SpineViewer/MainForm.Designer.cs
index c76d136..00ad320 100644
--- a/SpineViewer/MainForm.Designer.cs
+++ b/SpineViewer/MainForm.Designer.cs
@@ -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
//
diff --git a/SpineViewer/MainForm.cs b/SpineViewer/MainForm.cs
index 9237197..c488dec 100644
--- a/SpineViewer/MainForm.cs
+++ b/SpineViewer/MainForm.cs
@@ -18,6 +18,10 @@ namespace SpineViewer
{
InitializeComponent();
InitializeLogConfiguration();
+
+ // 在此处将导出菜单需要的类绑定起来
+ toolStripMenuItem_ExportFrame.Tag = ExportType.Frame;
+ toolStripMenuItem_ExportFrameSequence.Tag = ExportType.FrameSequence;
}
///
@@ -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();
}