From 9d9edb8bc437d39b5ad01605ad2563e5e59b3e60 Mon Sep 17 00:00:00 2001 From: ww-rm Date: Sun, 26 Oct 2025 16:16:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20export=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SpineViewerCLI/ExportCommand.cs | 463 +++++++++++++++++++++++++++ SpineViewerCLI/Extension.cs | 88 +++++ SpineViewerCLI/SpineViewerCLI.cs | 395 ++++------------------- SpineViewerCLI/SpineViewerCLI.csproj | 1 + SpineViewerCLI/Utils.cs | 67 ++++ 5 files changed, 677 insertions(+), 337 deletions(-) create mode 100644 SpineViewerCLI/ExportCommand.cs create mode 100644 SpineViewerCLI/Extension.cs create mode 100644 SpineViewerCLI/Utils.cs diff --git a/SpineViewerCLI/ExportCommand.cs b/SpineViewerCLI/ExportCommand.cs new file mode 100644 index 0000000..35c7900 --- /dev/null +++ b/SpineViewerCLI/ExportCommand.cs @@ -0,0 +1,463 @@ +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 val) && val < 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 val) && val < 0) + r.AddError($"{OptTime.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}"); + } + } + } +} diff --git a/SpineViewerCLI/Extension.cs b/SpineViewerCLI/Extension.cs new file mode 100644 index 0000000..b5b3257 --- /dev/null +++ b/SpineViewerCLI/Extension.cs @@ -0,0 +1,88 @@ +using SFML.Graphics; +using SFML.System; +using Spine; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SpineViewerCLI +{ + public static class Extension + { + /// + /// 获取一个对象副本, 继承所有状态 + /// + public static SpineObject Copy(this SpineObject self, bool keepTrackTime = false) + { + var spineObject = new SpineObject(self, true); + + // 拷贝轨道动画, 但是仅拷贝第一个条目 + foreach (var tr in self.AnimationState.IterTracks().Where(t => t is not null)) + { + var t = spineObject.AnimationState.SetAnimation(tr!.TrackIndex, tr.Animation, tr.Loop); + t.TimeScale = tr.TimeScale; + t.Alpha = tr.Alpha; + if (keepTrackTime) + t.TrackTime = tr.TrackTime; + } + + // XXX(#105): 部分 3.4.02 版本模型在设置动画后出现附件残留, 因此强制进行一次 Setup + if (spineObject.Version == SpineVersion.V34) + { + spineObject.Skeleton.SetSlotsToSetupPose(); + } + + spineObject.Update(0); + return spineObject; + } + + /// + /// 获取当前状态包围盒 + /// + public static FloatRect GetCurrentBounds(this SpineObject self) + { + self.Skeleton.GetBounds(out var x, out var y, out var w, out var h); + return new(x, y, Math.Max(w, 1e-6f), Math.Max(h, 1e-6f)); + } + + /// + /// 计算所有轨道第一个条目的动画时长最大值 + /// + /// + /// + public static float GetAnimationMaxDuration(this SpineObject self) + { + return self.AnimationState.IterTracks().Select(t => t?.Animation.Duration ?? 0).DefaultIfEmpty(0).Max(); + } + + /// + /// 合并另一个矩形 + /// + public static FloatRect Union(this FloatRect self, FloatRect rect) + { + float left = Math.Min(self.Left, rect.Left); + float top = Math.Min(self.Top, rect.Top); + float right = Math.Max(self.Left + self.Width, rect.Left + rect.Width); + float bottom = Math.Max(self.Top + self.Height, rect.Top + rect.Height); + return new(left, top, right - left, bottom - top); + } + + /// + /// 按给定的帧率获取所有轨道第一个条目动画全时长包围盒大小, 是一个耗时操作, 如果可能的话最好缓存结果 + /// + public static FloatRect GetAnimationBounds(this SpineObject self, float fps = 30) + { + using var copy = self.Copy(); + var bounds = copy.GetCurrentBounds(); + var maxDuration = copy.GetAnimationMaxDuration(); + for (float tick = 0, delta = 1 / fps; tick < maxDuration; tick += delta) + { + bounds = bounds.Union(copy.GetCurrentBounds()); + copy.Update(delta); + } + return bounds; + } + } +} diff --git a/SpineViewerCLI/SpineViewerCLI.cs b/SpineViewerCLI/SpineViewerCLI.cs index 9794543..9a364d4 100644 --- a/SpineViewerCLI/SpineViewerCLI.cs +++ b/SpineViewerCLI/SpineViewerCLI.cs @@ -1,364 +1,85 @@ -using System.Globalization; +using NLog; using SFML.Graphics; using SFML.System; +using SkiaSharp; using Spine; using Spine.Exporters; -using SkiaSharp; +using System.CommandLine; +using System.Globalization; namespace SpineViewerCLI { - public class CLI + public static class SpineViewerCLI { - const string USAGE = @" -usage: SpineViewerCLI.exe [--skel PATH] [--atlas PATH] [--output PATH] [--animation STR] [--skin STR] [--hide-slot STR] [--pma] [--fps INT] [--loop] [--crf INT] [--time FLOAT] [--quality INT] [--width INT] [--height INT] [--centerx INT] [--centery INT] [--zoom FLOAT] [--speed FLOAT] [--color HEX] [--quiet] - -options: - --skel PATH Path to the .skel file - --atlas PATH Path to the .atlas file, default searches in the skel file directory - --output PATH Output file path. Extension determines export type (.mp4, .webm for video; .png, .jpg for frame) - --animation STR Animation name - --skin STR Skin name to apply. Can be used multiple times to stack skins. - --hide-slot STR Slot name to hide. Can be used multiple times. - --pma Use premultiplied alpha, default false - --fps INT Frames per second (for video), default 24 - --loop Whether to loop the animation (for video), default false - --crf INT Constant Rate Factor (for video), from 0 (lossless) to 51 (worst), default 23 - --time FLOAT Time in seconds to export a single frame. Providing this argument forces frame export mode. - --quality INT Quality for lossy image formats (jpg, webp), from 0 to 100, default 80 - --width INT Output width, default 512 - --height INT Output height, default 512 - --centerx INT Center X offset, default automatically finds bounds - --centery INT Center Y offset, default automatically finds bounds - --zoom FLOAT Zoom level, default 1.0 - --speed FLOAT Speed of animation (for video), default 1.0 - --color HEX Background color as a hex RGBA color, default 000000ff (opaque black) - --quiet Removes console progress log, default false - --warmup INT Warm Up Physics, default 2 loops before export -"; - - public static void Main(string[] args) + public static Option OptQuiet { get; } = new("--quiet", "-q") { - string? skelPath = null; - string? atlasPath = null; - string? output = null; - string? animation = null; - var skins = new List(); - var hideSlots = new List(); - bool pma = false; - uint fps = 24; - bool loop = false; - int crf = 23; - float? time = null; - int quality = 80; - uint? width = null; - uint? height = null; - int? centerx = null; - int? centery = null; - float zoom = 1; - float speed = 1; - Color backgroundColor = Color.Black; - bool quiet = false; - bool warmup = false; - int warmUpLoops = 2; + Description = "Suppress console logging (quiet mode).", + Recursive = true, + }; - for (int i = 0; i < args.Length; i++) + public static int Main(string[] args) + { + InitializeFileLog(); + + var cmdExport = new ExportCommand(); + + var cmdRoot = new RootCommand("Root Command") { - switch (args[i]) - { - case "--help": - Console.Write(USAGE); - Environment.Exit(0); - break; - case "--skel": - skelPath = args[++i]; - break; - case "--atlas": - atlasPath = args[++i]; - break; - case "--output": - output = args[++i]; - break; - case "--animation": - animation = args[++i]; - break; - case "--skin": - skins.Add(args[++i]); - break; - case "--hide-slot": - hideSlots.Add(args[++i]); - break; - case "--pma": - pma = true; - break; - case "--fps": - fps = uint.Parse(args[++i]); - break; - case "--loop": - loop = true; - break; - case "--crf": - crf = int.Parse(args[++i]); - break; - case "--time": - time = float.Parse(args[++i]); - break; - case "--quality": - quality = int.Parse(args[++i]); - break; - case "--width": - width = uint.Parse(args[++i]); - break; - case "--height": - height = uint.Parse(args[++i]); - break; - case "--centerx": - centerx = int.Parse(args[++i]); - break; - case "--centery": - centery = int.Parse(args[++i]); - break; - case "--zoom": - zoom = float.Parse(args[++i]); - break; - case "--speed": - speed = float.Parse(args[++i]); - break; - case "--color": - backgroundColor = new Color(uint.Parse(args[++i], NumberStyles.HexNumber)); - break; - case "--quiet": - quiet = true; - break; - case "--warmup": - warmUpLoops = int.Parse(args[++i]); - warmup = true; - break; - default: - Console.Error.WriteLine($"Unknown argument: {args[i]}"); - Environment.Exit(2); - break; - } - } + OptQuiet, + cmdExport, + }; - if (string.IsNullOrEmpty(skelPath)) - { - Console.Error.WriteLine("Missing --skel"); - Environment.Exit(2); - } - if (string.IsNullOrEmpty(output)) - { - Console.Error.WriteLine("Missing --output"); - Environment.Exit(2); - } - var outputExtension = Path.GetExtension(output).TrimStart('.').ToLowerInvariant(); + var result = cmdRoot.Parse(args); - var sp = new SpineObject(skelPath, atlasPath); - sp.UsePma = pma; + if (!result.GetValue(OptQuiet)) + InitializeConsoleLog(); - foreach (var skinName in skins) - { - if (!sp.SetSkinStatus(skinName, true)) - { - var availableSkins = string.Join(", ", sp.Data.Skins.Select(s => s.Name)); - Console.Error.WriteLine($"Error: Skin '{skinName}' not found. Available skins: {availableSkins}"); - Environment.Exit(2); - } - } - - if (string.IsNullOrEmpty(animation)) - { - var availableAnimations = string.Join(", ", sp.Data.Animations.Select(a => a.Name)); - Console.Error.WriteLine($"Missing --animation. Available animations for {sp.Name}: {availableAnimations}"); - Environment.Exit(2); - } - - var trackEntry = sp.AnimationState.SetAnimation(0, animation, loop); - if (time.HasValue) - { - trackEntry.TrackTime = time.Value; - } - sp.Update(0); - - if (warmup) - { - sp.Update(trackEntry.Animation.Duration * warmUpLoops); - } - - foreach (var slotName in hideSlots) - { - if (!sp.SetSlotVisible(slotName, false)) - { - if (!quiet) Console.WriteLine($"Warning: Slot '{slotName}' not found, cannot hide."); - } - } - - if (time.HasValue) - { - if (TryGetImageFormat(outputExtension, out var imageFormat)) - { - if (!quiet) Console.WriteLine($"Exporting single frame at {time.Value:F2}s to {output}..."); - - FrameExporter exporter; - if (width is uint w && height is uint h && centerx is int cx && centery is int cy) - { - exporter = new FrameExporter(w, h) - { - Center = (cx, cy), - Size = (w / zoom, -h / zoom), - }; - } - else - { - var frameBounds = GetSpineObjectBounds(sp); - var bounds = GetFloatRectCanvasBounds(frameBounds, new(width ?? 512, height ?? 512)); - exporter = new FrameExporter(width ?? (uint)Math.Ceiling(bounds.Width), height ?? (uint)Math.Ceiling(bounds.Height)) - { - Center = bounds.Position + bounds.Size / 2, - Size = (bounds.Width, -bounds.Height), - }; - } - exporter.Format = imageFormat; - exporter.Quality = quality; - exporter.BackgroundColor = backgroundColor; - - exporter.Export(output, sp); - - if (!quiet) - Console.WriteLine("Frame export complete."); - } - else - { - var validImageExtensions = "png, jpg, jpeg, webp, bmp"; - Console.Error.WriteLine($"Error: --time argument requires a valid image format extension. Supported formats are: {validImageExtensions}."); - Environment.Exit(2); - } - } - else if (Enum.TryParse(outputExtension, true, out var videoFormat)) - { - FFmpegVideoExporter exporter; - if (width is uint w && height is uint h && centerx is int cx && centery is int cy) - { - exporter = new FFmpegVideoExporter(w, h) - { - Center = (cx, cy), - Size = (w / zoom, -h / zoom), - }; - } - else - { - var bounds = GetFloatRectCanvasBounds(GetSpineObjectAnimationBounds(sp, fps), new(width ?? 512, height ?? 512)); - exporter = new FFmpegVideoExporter(width ?? (uint)Math.Ceiling(bounds.Width), height ?? (uint)Math.Ceiling(bounds.Height)) - { - Center = bounds.Position + bounds.Size / 2, - Size = (bounds.Width, -bounds.Height), - }; - } - exporter.Duration = trackEntry.Animation.Duration; - exporter.Fps = fps; - exporter.Format = videoFormat; - exporter.Loop = loop; - exporter.Crf = crf; - exporter.Speed = speed; - exporter.BackgroundColor = backgroundColor; - - if (!quiet) - exporter.ProgressReporter = (total, done, text) => Console.Write($"\r{text}"); - - using var cts = new CancellationTokenSource(); - exporter.Export(output, cts.Token, sp); - - if (!quiet) - Console.WriteLine("\nVideo export complete."); - } - else - { - var validVideoExtensions = string.Join(", ", Enum.GetNames(typeof(FFmpegVideoExporter.VideoFormat))); - var validImageExtensions = "png, jpg, jpeg, webp, bmp"; - Console.Error.WriteLine($"Invalid output extension or missing --time for image export. Supported video formats are: {validVideoExtensions}. Supported image formats (with --time) are: {validImageExtensions}."); - Environment.Exit(2); - } - - Environment.Exit(0); + return result.Invoke(); } - private static bool TryGetImageFormat(string extension, out SKEncodedImageFormat format) + private static void InitializeFileLog() { - switch (extension) + var config = new NLog.Config.LoggingConfiguration(); + var fileTarget = new NLog.Targets.FileTarget("fileTarget") { - case "png": - format = SKEncodedImageFormat.Png; - return true; - case "jpg": - case "jpeg": - format = SKEncodedImageFormat.Jpeg; - return true; - case "webp": - format = SKEncodedImageFormat.Webp; - return true; - case "bmp": - format = SKEncodedImageFormat.Bmp; - return true; - default: - format = default; - return false; - } + Encoding = System.Text.Encoding.UTF8, + Layout = "${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${level:uppercase=true} - ${processid} - ${callsite-filename:includeSourcePath=false}:${callsite-linenumber} - ${message}", + AutoFlush = true, + FileName = "${basedir}/logs/cli.log", + ArchiveFileName = "${basedir}/logs/cli.{#}.log", + ArchiveNumbering = NLog.Targets.ArchiveNumberingMode.Rolling, + ArchiveAboveSize = 1048576, + MaxArchiveFiles = 5, + ConcurrentWrites = true, + KeepFileOpen = false, + }; + + config.AddTarget(fileTarget); + config.AddRule(LogLevel.Trace, LogLevel.Fatal, fileTarget); + LogManager.Configuration = config; } - public static SpineObject CopySpineObject(SpineObject sp) + private static void InitializeConsoleLog() { - var spineObject = new SpineObject(sp, true); - foreach (var tr in sp.AnimationState.IterTracks().Where(t => t is not null)) + var config = new NLog.Config.LoggingConfiguration(); + var consoleTarget = new NLog.Targets.ColoredConsoleTarget("consoleTarget") { - var t = spineObject.AnimationState.SetAnimation(tr!.TrackIndex, tr.Animation, tr.Loop); - } - spineObject.Update(0); - return spineObject; - } + Encoding = System.Text.Encoding.UTF8, + Layout = "[${level:format=OneLetter}]${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${message}", + AutoFlush = true, + DetectConsoleAvailable = true, + StdErr = true, + DetectOutputRedirected = true, + }; - static FloatRect GetSpineObjectBounds(SpineObject sp) - { - sp.Skeleton.GetBounds(out var x, out var y, out var w, out var h); - return new(x, y, Math.Max(w, 1e-6f), Math.Max(h, 1e-6f)); - } - static FloatRect FloatRectUnion(FloatRect a, FloatRect b) - { - float left = Math.Min(a.Left, b.Left); - float top = Math.Min(a.Top, b.Top); - float right = Math.Max(a.Left + a.Width, b.Left + b.Width); - float bottom = Math.Max(a.Top + a.Height, b.Top + b.Height); - return new FloatRect(left, top, right - left, bottom - top); - } - static FloatRect GetSpineObjectAnimationBounds(SpineObject sp, float fps = 10) - { - sp = CopySpineObject(sp); - var bounds = GetSpineObjectBounds(sp); - var maxDuration = sp.AnimationState.IterTracks().Select(t => t?.Animation.Duration ?? 0).DefaultIfEmpty(0).Max(); - sp.Update(0); - for (float tick = 0, delta = 1 / fps; tick < maxDuration; tick += delta) - { - bounds = FloatRectUnion(bounds, GetSpineObjectBounds(sp)); - sp.Update(delta); - } - return bounds; - } - static FloatRect GetFloatRectCanvasBounds(FloatRect rect, Vector2u resolution) - { - float sizeW = rect.Width; - float sizeH = rect.Height; - float innerW = resolution.X; - float innerH = resolution.Y; - var scale = Math.Max(Math.Abs(sizeW / innerW), Math.Abs(sizeH / innerH)); - var scaleW = scale * Math.Sign(sizeW); - var scaleH = scale * Math.Sign(sizeH); + consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Info", NLog.Targets.ConsoleOutputColor.DarkGray, NLog.Targets.ConsoleOutputColor.NoChange)); + consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Warn", NLog.Targets.ConsoleOutputColor.DarkYellow, NLog.Targets.ConsoleOutputColor.NoChange)); + consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Error", NLog.Targets.ConsoleOutputColor.Red, NLog.Targets.ConsoleOutputColor.NoChange)); + consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Fatal", NLog.Targets.ConsoleOutputColor.White, NLog.Targets.ConsoleOutputColor.DarkRed)); - innerW *= scaleW; - innerH *= scaleH; - - var x = rect.Left - (innerW - sizeW) / 2; - var y = rect.Top - (innerH - sizeH) / 2; - var w = resolution.X * scaleW; - var h = resolution.Y * scaleH; - return new(x, y, w, h); + config.AddTarget(consoleTarget); + config.AddRule(LogLevel.Info, LogLevel.Fatal, consoleTarget); + LogManager.Configuration = config; } } } diff --git a/SpineViewerCLI/SpineViewerCLI.csproj b/SpineViewerCLI/SpineViewerCLI.csproj index 6184551..d34e33f 100644 --- a/SpineViewerCLI/SpineViewerCLI.csproj +++ b/SpineViewerCLI/SpineViewerCLI.csproj @@ -16,6 +16,7 @@ + diff --git a/SpineViewerCLI/Utils.cs b/SpineViewerCLI/Utils.cs new file mode 100644 index 0000000..8877f4f --- /dev/null +++ b/SpineViewerCLI/Utils.cs @@ -0,0 +1,67 @@ +using SFML.Graphics; +using System; +using System.Collections.Generic; +using System.CommandLine.Parsing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SpineViewerCLI +{ + public static class Utils + { + public static Color ParseColor(ArgumentResult result) + { + var token = result.Tokens.Count > 0 ? result.Tokens[0].Value : null; + if (string.IsNullOrWhiteSpace(token)) + return Color.Black; + + try + { + // 去掉开头的 # + var hex = token.Trim().TrimStart('#'); + + // 支持格式: RGB / ARGB / RRGGBB / AARRGGBB + if (hex.Length == 3) + { + // #RGB → #RRGGBB + var r = hex[0]; + var g = hex[1]; + var b = hex[2]; + hex = $"{r}{r}{g}{g}{b}{b}"; + hex = "FF" + hex; // 加上不透明 alpha + } + else if (hex.Length == 4) + { + // #ARGB → #AARRGGBB + var a = hex[0]; + var r = hex[1]; + var g = hex[2]; + var b = hex[3]; + hex = $"{a}{a}{r}{r}{g}{g}{b}{b}"; + } + else if (hex.Length == 6) + { + // #RRGGBB → #AARRGGBB + hex = "FF" + hex; + } + else if (hex.Length != 8) + { + result.AddError("Invalid color format. Use #RGB, #ARGB, #RRGGBB, or #AARRGGBB."); + return Color.Black; + } + + var aVal = Convert.ToByte(hex[..2], 16); + var rVal = Convert.ToByte(hex.Substring(2, 2), 16); + var gVal = Convert.ToByte(hex.Substring(4, 2), 16); + var bVal = Convert.ToByte(hex.Substring(6, 2), 16); + return new(rVal, gVal, bVal, aVal); + } + catch + { + result.AddError("Invalid color format."); + return Color.Black; + } + } + } +}