using NLog; using SFML.Graphics; using Spine; using Spine.Exporters; using System; using System.Collections.Generic; using System.CommandLine; using System.Linq; using System.Reflection; using System.Text; using System.Threading.Tasks; using System.Windows; namespace SpineViewerCLI { public enum ExportFormat { Png = 0x0100, Jpg = 0x0101, Webp = 0x0102, Bmp = 0x0103, Frames = 0x0200, Gif = 0x0300, Webpa = 0x0301, Apng = 0x0302, Mp4 = 0x0303, Webm = 0x0304, Mkv = 0x0305, Mov = 0x0306, Custom = 0x0400, } public class ExportCommand : Command { private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); private static readonly string _name = "export"; private static readonly string _desc = "Export single model"; #region >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 基本参数 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< public Argument ArgSkel { get; } = new("skel") { Description = "Path of skel file.", }; public Option OptFormat { get; } = new("--format", "-f") { Description = "Export format.", Required = true, }; public Option OptOutput { get; } = new("--output", "-o") { Description = "Output file or directory. Use a directory for frame sequence export.", Required = true, }; public Option OptAnimations { get; } = new("--animations", "-a") { Description = "Animations to export. Supports multiple entries, placed in order on tracks starting from 0。", Required = true, Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = true, }; public Option OptAtlas { get; } = new("--atlas") { Description = "Path to the atlas file that matches the skel file.", }; public Option OptScale { get; } = new("--scale") { Description = "Scale factor of the model.", DefaultValueFactory = _ => 1f, }; public Option OptPma { get; } = new("--pma") { Description = "Specifies whether the texture uses PMA (premultiplied alpha) format.", }; public Option OptSkins { get; } = new("--skins") { Description = "Skins to export. Multiple skins can be specified.", Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = true, }; public Option OptDisableSlots { get; } = new("--disable-slots") { Description = "Slots to disable during export. Multiple slots can be specified.", Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = true, }; public Option OptWarmUp { get; } = new("--warm-up") { Description = "Warm-up duration of the animation, used to stabilize physics effects. A negative value will automatically warm up for the maximum duration among all animations.", DefaultValueFactory = _ => 0f, }; public Option OptNoProgress { get; } = new("--no-progress") { Description = "Do not display real-time progress.", }; #endregion #region >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 基本导出参数 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< public Option OptColor { get; } = new("--color") { Description = "Background color of content.", //DefaultValueFactory = ... CustomParser = Utils.ParseColor }; public Option OptMargin { get; } = new("--margin") { Description = "Size of the margin (in pixels) around the content.", DefaultValueFactory = _ => 0u, }; public Option OptMaxResolution { get; } = new("--max-resolution") { Description = "Maximum width or height (in pixels) for exported images.", DefaultValueFactory = _ => 2048u, }; public Option OptTime { get; } = new("--time") { Description = "Start time offset of the animation.", DefaultValueFactory = _ => 0f, }; public Option OptDuration { get; } = new("--duration") { Description = "Export duration. Negative values indicate automatic duration calculation.", DefaultValueFactory = _ => -1f, }; public Option OptFps { get; } = new("--fps") { Description = "Frame rate for export.", DefaultValueFactory = _ => 30u, }; public Option OptSpeed { get; } = new("--speed") { Description = "Speed factor for the exported animation.", DefaultValueFactory = _ => 1f, }; public Option OptDropLastFrame { get; } = new("--drop-last-frame") { Description = "Whether to drop the incomplete last frame.", }; #endregion #region >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 格式参数 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< public Option OptQuality { get; } = new("--quality") { Description = "Image quality.", DefaultValueFactory = _ => 80u, }; public Option OptLoop { get; } = new("--loop") { Description = "Whether the animation should loop.", }; public Option OptLossless { get; } = new("--lossless") { Description = "Whether to encode the WebP animation losslessly.", }; public Option OptApngPredMethod { get; } = new("--apng-pred") { Description = "Prediction method used for APNG animations.", DefaultValueFactory = _ => FFmpegVideoExporter.ApngPredMethod.Mixed, }; public Option OptCrf { get; } = new("--crf") { Description = "CRF (Constant Rate Factor) value for encoding.", DefaultValueFactory = _ => 23u, }; public Option OptMovProfile { get; } = new("--mov-profile") { Description = "Profile setting for MOV format export.", DefaultValueFactory = _ => FFmpegVideoExporter.MovProfile.Yuv4444Extreme, }; #endregion #region >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 自定义导出格式参数 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< public Option OptFFFormat { get; } = new("--ff-format") { Description = "format option of ffmpeg", }; public Option OptFFCodec { get; } = new("--ff-codec") { Description = "codec option of ffmpeg", }; public Option OptFFPixelFormat { get; } = new("--ff-pixfmt") { Description = "pixel format option of ffmpeg", }; public Option OptFFBitrate { get; } = new("--ff-bitrate") { Description = "bitrate option of ffmpeg", }; public Option OptFFFilter { get; } = new("--ff-filter") { Description = "filter option of ffmpeg", }; public Option OptFFArgs { get; } = new("--ff-args") { Description = "other arguments of ffmpeg", }; #endregion public ExportCommand() : base(_name, _desc) { OptColor.DefaultValueFactory = r => { var defVal = Color.Black; try { switch (r.GetValue(OptFormat)) { case ExportFormat.Png: case ExportFormat.Webp: case ExportFormat.Frames: case ExportFormat.Gif: case ExportFormat.Webpa: case ExportFormat.Apng: case ExportFormat.Webm: defVal = Color.Transparent; break; } } catch (InvalidOperationException) { } // 未提供 OptFormat 的时候 GetValue 会报错 return defVal; }; OptScale.Validators.Add(r => { if (r.Tokens.Count > 0 && float.TryParse(r.Tokens[0].Value, out var v) && v < 0) r.AddError($"{OptScale.Name} must be non-negative."); }); OptTime.Validators.Add(r => { if (r.Tokens.Count > 0 && float.TryParse(r.Tokens[0].Value, out var v) && v < 0) r.AddError($"{OptTime.Name} must be non-negative."); }); OptSpeed.Validators.Add(r => { if (r.Tokens.Count > 0 && float.TryParse(r.Tokens[0].Value, out var v) && v < 0) r.AddError($"{OptSpeed.Name} must be non-negative."); }); // 用反射查找自己所有的公开属性是 Argument 或者 Option 的 foreach (var prop in GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) { var value = prop.GetValue(this); if (value is Argument arg) Add(arg); else if (value is Option opt) Add(opt); } SetAction(ExportAction); } private void ExportAction(ParseResult result) { // 读取模型 using var spine = new SpineObject(result.GetValue(ArgSkel)!.FullName, result.GetValue(OptAtlas)?.FullName); // 设置模型参数 spine.Skeleton.ScaleX = spine.Skeleton.ScaleY = result.GetValue(OptScale); spine.UsePma = result.GetValue(OptPma); // 设置要导出的动画 int trackIdx = 0; foreach (var name in result.GetValue(OptAnimations)) { if (!spine.Data.AnimationsByName.ContainsKey(name)) { _logger.Warn("No animation named '{0}', skip it", name); continue; } spine.AnimationState.SetAnimation(trackIdx, name, true); trackIdx++; } // 设置需要启用的皮肤 foreach (var name in result.GetValue(OptSkins)) { if (!spine.SetSkinStatus(name, true)) { _logger.Warn("Failed to enable skin '{0}'", name); } } // 设置需要屏蔽的插槽 foreach (var name in result.GetValue(OptDisableSlots)) { if (!spine.SetSlotVisible(name, false)) { _logger.Warn("Failed to disable slot '{0}'", name); } } // TODO: 设置要启用的插槽 // 时间轴处理 spine.Update(result.GetValue(OptTime)); var warmup = result.GetValue(OptWarmUp); spine.Update(warmup < 0 ? spine.GetAnimationMaxDuration() : warmup); var exporter = GetExporterFilledWithArgs(result, spine); // 创建输出目录 string output = result.GetValue(OptOutput); Directory.CreateDirectory(exporter is FrameSequenceExporter ? output : Path.GetDirectoryName(output)); // 挂载进度报告函数 if (!result.GetValue(OptNoProgress)) { exporter.ProgressReporter = (total, done, text) => { Console.Write($"\r{text}"); if (total == done) Console.WriteLine(""); }; } // 导出 exporter.Export(output, spine); } private BaseExporter GetExporterFilledWithArgs(ParseResult result, SpineObject spine) { var formatType = (int)result.GetValue(OptFormat) >> 8; // 根据模型获取自动分辨率和视区参数 var maxResolution = result.GetValue(OptMaxResolution); var margin = result.GetValue(OptMargin); var bounds = formatType == 0x01 ? spine.GetCurrentBounds() : spine.GetAnimationBounds(result.GetValue(OptFps)); var resolution = new SFML.System.Vector2u((uint)bounds.Size.X, (uint)bounds.Size.Y); if (resolution.X >= maxResolution || resolution.Y >= maxResolution) { // 缩小到最大像素限制 var scale = Math.Min(maxResolution / bounds.Width, maxResolution / bounds.Height); resolution.X = (uint)(bounds.Width * scale); resolution.Y = (uint)(bounds.Height * scale); } var viewBounds = bounds.GetCanvasBounds(resolution, margin); var duration = result.GetValue(OptDuration); if (duration < 0) duration = spine.GetAnimationMaxDuration(); if (formatType == 0x01) { return new FrameExporter(resolution.X + margin * 2, resolution.Y + margin * 2) { Size = new(viewBounds.Width, -viewBounds.Height), Center = viewBounds.Position + viewBounds.Size / 2, Rotation = 0, BackgroundColor = result.GetValue(OptColor), Format = result.GetValue(OptFormat) switch { ExportFormat.Png => SkiaSharp.SKEncodedImageFormat.Png, ExportFormat.Jpg => SkiaSharp.SKEncodedImageFormat.Jpeg, ExportFormat.Webp => SkiaSharp.SKEncodedImageFormat.Webp, ExportFormat.Bmp => SkiaSharp.SKEncodedImageFormat.Bmp, var v => throw new InvalidOperationException($"{v}"), }, Quality = (int)result.GetValue(OptQuality), }; } else if (formatType == 0x02) { return new FrameSequenceExporter(resolution.X + margin * 2, resolution.Y + margin * 2) { Size = new(viewBounds.Width, -viewBounds.Height), Center = viewBounds.Position + viewBounds.Size / 2, Rotation = 0, BackgroundColor = result.GetValue(OptColor), Fps = result.GetValue(OptFps), Speed = result.GetValue(OptSpeed), KeepLast = !result.GetValue(OptDropLastFrame), Duration = duration, }; } else if (formatType == 0x03) { return new FFmpegVideoExporter(resolution.X + margin * 2, resolution.Y + margin * 2) { Size = new(viewBounds.Width, -viewBounds.Height), Center = viewBounds.Position + viewBounds.Size / 2, Rotation = 0, BackgroundColor = result.GetValue(OptColor), Fps = result.GetValue(OptFps), Speed = result.GetValue(OptSpeed), KeepLast = !result.GetValue(OptDropLastFrame), Duration = duration, Format = result.GetValue(OptFormat) switch { ExportFormat.Gif => FFmpegVideoExporter.VideoFormat.Gif, ExportFormat.Webpa => FFmpegVideoExporter.VideoFormat.Webp, ExportFormat.Apng => FFmpegVideoExporter.VideoFormat.Apng, ExportFormat.Mp4 => FFmpegVideoExporter.VideoFormat.Mp4, ExportFormat.Webm => FFmpegVideoExporter.VideoFormat.Webm, ExportFormat.Mkv => FFmpegVideoExporter.VideoFormat.Mkv, ExportFormat.Mov => FFmpegVideoExporter.VideoFormat.Mov, var v => throw new InvalidOperationException($"{v}"), }, Quality = (int)result.GetValue(OptQuality), Loop = result.GetValue(OptLoop), Lossless = result.GetValue(OptLossless), PredMethod = result.GetValue(OptApngPredMethod), Crf = (int)result.GetValue(OptCrf), Profile = result.GetValue(OptMovProfile), } ; } else if (formatType == 0x04) { return new CustomFFmpegExporter(resolution.X + margin * 2, resolution.Y + margin * 2) { Size = new(viewBounds.Width, -viewBounds.Height), Center = viewBounds.Position + viewBounds.Size / 2, Rotation = 0, BackgroundColor = result.GetValue(OptColor), Fps = result.GetValue(OptFps), Speed = result.GetValue(OptSpeed), KeepLast = !result.GetValue(OptDropLastFrame), Duration = duration, Format = result.GetValue(OptFFFormat), Codec = result.GetValue(OptFFCodec), PixelFormat = result.GetValue(OptFFPixelFormat), Bitrate = result.GetValue(OptFFBitrate), Filter = result.GetValue(OptFFFilter), CustomArgs = result.GetValue(OptFFArgs), }; } else { throw new ArgumentOutOfRangeException($"Unknown format type {formatType}"); } } } }