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(); }