增加 export 命令
This commit is contained in:
463
SpineViewerCLI/ExportCommand.cs
Normal file
463
SpineViewerCLI/ExportCommand.cs
Normal file
@@ -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<FileInfo> ArgSkel { get; } = new("skel")
|
||||||
|
{
|
||||||
|
Description = "Path of skel file.",
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<ExportFormat> OptFormat { get; } = new("--format", "-f")
|
||||||
|
{
|
||||||
|
Description = "Export format.",
|
||||||
|
Required = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<string> OptOutput { get; } = new("--output", "-o")
|
||||||
|
{
|
||||||
|
Description = "Output file or directory. Use a directory for frame sequence export.",
|
||||||
|
Required = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<string[]> 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<FileInfo> OptAtlas { get; } = new("--atlas")
|
||||||
|
{
|
||||||
|
Description = "Path to the atlas file that matches the skel file.",
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<float> OptScale { get; } = new("--scale")
|
||||||
|
{
|
||||||
|
Description = "Scale factor of the model.",
|
||||||
|
DefaultValueFactory = _ => 1f,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<bool> OptPma { get; } = new("--pma")
|
||||||
|
{
|
||||||
|
Description = "Specifies whether the texture uses PMA (premultiplied alpha) format.",
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<string[]> OptSkins { get; } = new("--skins")
|
||||||
|
{
|
||||||
|
Description = "Skins to export. Multiple skins can be specified.",
|
||||||
|
Arity = ArgumentArity.OneOrMore,
|
||||||
|
AllowMultipleArgumentsPerToken = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<string[]> OptDisableSlots { get; } = new("--disable-slots")
|
||||||
|
{
|
||||||
|
Description = "Slots to disable during export. Multiple slots can be specified.",
|
||||||
|
Arity = ArgumentArity.OneOrMore,
|
||||||
|
AllowMultipleArgumentsPerToken = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<float> 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<bool> OptNoProgress { get; } = new("--no-progress")
|
||||||
|
{
|
||||||
|
Description = "Do not display real-time progress.",
|
||||||
|
};
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 基本导出参数 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
|
||||||
|
|
||||||
|
public Option<Color> OptColor { get; } = new("--color")
|
||||||
|
{
|
||||||
|
Description = "Background color of content.",
|
||||||
|
//DefaultValueFactory = ...
|
||||||
|
CustomParser = Utils.ParseColor
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<uint> OptMargin { get; } = new("--margin")
|
||||||
|
{
|
||||||
|
Description = "Size of the margin (in pixels) around the content.",
|
||||||
|
DefaultValueFactory = _ => 0u,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<uint> OptMaxResolution { get; } = new("--max-resolution")
|
||||||
|
{
|
||||||
|
Description = "Maximum width or height (in pixels) for exported images.",
|
||||||
|
DefaultValueFactory = _ => 2048u,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<float> OptTime { get; } = new("--time")
|
||||||
|
{
|
||||||
|
Description = "Start time offset of the animation.",
|
||||||
|
DefaultValueFactory = _ => 0f,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<float> OptDuration { get; } = new("--duration")
|
||||||
|
{
|
||||||
|
Description = "Export duration. Negative values indicate automatic duration calculation.",
|
||||||
|
DefaultValueFactory = _ => -1f,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<uint> OptFps { get; } = new("--fps")
|
||||||
|
{
|
||||||
|
Description = "Frame rate for export.",
|
||||||
|
DefaultValueFactory = _ => 30u,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<float> OptSpeed { get; } = new("--speed")
|
||||||
|
{
|
||||||
|
Description = "Speed factor for the exported animation.",
|
||||||
|
DefaultValueFactory = _ => 1f,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<bool> OptDropLastFrame { get; } = new("--drop-last-frame")
|
||||||
|
{
|
||||||
|
Description = "Whether to drop the incomplete last frame.",
|
||||||
|
};
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 格式参数 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
|
||||||
|
|
||||||
|
public Option<uint> OptQuality { get; } = new("--quality")
|
||||||
|
{
|
||||||
|
Description = "Image quality.",
|
||||||
|
DefaultValueFactory = _ => 80u,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<bool> OptLoop { get; } = new("--loop")
|
||||||
|
{
|
||||||
|
Description = "Whether the animation should loop.",
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<bool> OptLossless { get; } = new("--lossless")
|
||||||
|
{
|
||||||
|
Description = "Whether to encode the WebP animation losslessly.",
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<FFmpegVideoExporter.ApngPredMethod> OptApngPredMethod { get; } = new("--apng-pred")
|
||||||
|
{
|
||||||
|
Description = "Prediction method used for APNG animations.",
|
||||||
|
DefaultValueFactory = _ => FFmpegVideoExporter.ApngPredMethod.Mixed,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<uint> OptCrf { get; } = new("--crf")
|
||||||
|
{
|
||||||
|
Description = "CRF (Constant Rate Factor) value for encoding.",
|
||||||
|
DefaultValueFactory = _ => 23u,
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<FFmpegVideoExporter.MovProfile> OptMovProfile { get; } = new("--mov-profile")
|
||||||
|
{
|
||||||
|
Description = "Profile setting for MOV format export.",
|
||||||
|
DefaultValueFactory = _ => FFmpegVideoExporter.MovProfile.Yuv4444Extreme,
|
||||||
|
};
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 自定义导出格式参数 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
|
||||||
|
|
||||||
|
public Option<string> OptFFFormat { get; } = new("--ff-format")
|
||||||
|
{
|
||||||
|
Description = "format option of ffmpeg",
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<string> OptFFCodec { get; } = new("--ff-codec")
|
||||||
|
{
|
||||||
|
Description = "codec option of ffmpeg",
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<string> OptFFPixelFormat { get; } = new("--ff-pixfmt")
|
||||||
|
{
|
||||||
|
Description = "pixel format option of ffmpeg",
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<string> OptFFBitrate { get; } = new("--ff-bitrate")
|
||||||
|
{
|
||||||
|
Description = "bitrate option of ffmpeg",
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<string> OptFFFilter { get; } = new("--ff-filter")
|
||||||
|
{
|
||||||
|
Description = "filter option of ffmpeg",
|
||||||
|
};
|
||||||
|
|
||||||
|
public Option<string> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
SpineViewerCLI/Extension.cs
Normal file
88
SpineViewerCLI/Extension.cs
Normal file
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取一个对象副本, 继承所有状态
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前状态包围盒
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计算所有轨道第一个条目的动画时长最大值
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="self"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static float GetAnimationMaxDuration(this SpineObject self)
|
||||||
|
{
|
||||||
|
return self.AnimationState.IterTracks().Select(t => t?.Animation.Duration ?? 0).DefaultIfEmpty(0).Max();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 合并另一个矩形
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按给定的帧率获取所有轨道第一个条目动画全时长包围盒大小, 是一个耗时操作, 如果可能的话最好缓存结果
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,364 +1,85 @@
|
|||||||
using System.Globalization;
|
using NLog;
|
||||||
using SFML.Graphics;
|
using SFML.Graphics;
|
||||||
using SFML.System;
|
using SFML.System;
|
||||||
|
using SkiaSharp;
|
||||||
using Spine;
|
using Spine;
|
||||||
using Spine.Exporters;
|
using Spine.Exporters;
|
||||||
using SkiaSharp;
|
using System.CommandLine;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
namespace SpineViewerCLI
|
namespace SpineViewerCLI
|
||||||
{
|
{
|
||||||
public class CLI
|
public static class SpineViewerCLI
|
||||||
{
|
{
|
||||||
const string USAGE = @"
|
public static Option<bool> OptQuiet { get; } = new("--quiet", "-q")
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
string? skelPath = null;
|
Description = "Suppress console logging (quiet mode).",
|
||||||
string? atlasPath = null;
|
Recursive = true,
|
||||||
string? output = null;
|
};
|
||||||
string? animation = null;
|
|
||||||
var skins = new List<string>();
|
|
||||||
var hideSlots = new List<string>();
|
|
||||||
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;
|
|
||||||
|
|
||||||
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])
|
OptQuiet,
|
||||||
{
|
cmdExport,
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(skelPath))
|
var result = cmdRoot.Parse(args);
|
||||||
{
|
|
||||||
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 sp = new SpineObject(skelPath, atlasPath);
|
if (!result.GetValue(OptQuiet))
|
||||||
sp.UsePma = pma;
|
InitializeConsoleLog();
|
||||||
|
|
||||||
foreach (var skinName in skins)
|
return result.Invoke();
|
||||||
{
|
|
||||||
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<FFmpegVideoExporter.VideoFormat>(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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":
|
Encoding = System.Text.Encoding.UTF8,
|
||||||
format = SKEncodedImageFormat.Png;
|
Layout = "${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${level:uppercase=true} - ${processid} - ${callsite-filename:includeSourcePath=false}:${callsite-linenumber} - ${message}",
|
||||||
return true;
|
AutoFlush = true,
|
||||||
case "jpg":
|
FileName = "${basedir}/logs/cli.log",
|
||||||
case "jpeg":
|
ArchiveFileName = "${basedir}/logs/cli.{#}.log",
|
||||||
format = SKEncodedImageFormat.Jpeg;
|
ArchiveNumbering = NLog.Targets.ArchiveNumberingMode.Rolling,
|
||||||
return true;
|
ArchiveAboveSize = 1048576,
|
||||||
case "webp":
|
MaxArchiveFiles = 5,
|
||||||
format = SKEncodedImageFormat.Webp;
|
ConcurrentWrites = true,
|
||||||
return true;
|
KeepFileOpen = false,
|
||||||
case "bmp":
|
};
|
||||||
format = SKEncodedImageFormat.Bmp;
|
|
||||||
return true;
|
config.AddTarget(fileTarget);
|
||||||
default:
|
config.AddRule(LogLevel.Trace, LogLevel.Fatal, fileTarget);
|
||||||
format = default;
|
LogManager.Configuration = config;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SpineObject CopySpineObject(SpineObject sp)
|
private static void InitializeConsoleLog()
|
||||||
{
|
{
|
||||||
var spineObject = new SpineObject(sp, true);
|
var config = new NLog.Config.LoggingConfiguration();
|
||||||
foreach (var tr in sp.AnimationState.IterTracks().Where(t => t is not null))
|
var consoleTarget = new NLog.Targets.ColoredConsoleTarget("consoleTarget")
|
||||||
{
|
{
|
||||||
var t = spineObject.AnimationState.SetAnimation(tr!.TrackIndex, tr.Animation, tr.Loop);
|
Encoding = System.Text.Encoding.UTF8,
|
||||||
}
|
Layout = "[${level:format=OneLetter}]${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${message}",
|
||||||
spineObject.Update(0);
|
AutoFlush = true,
|
||||||
return spineObject;
|
DetectConsoleAvailable = true,
|
||||||
}
|
StdErr = true,
|
||||||
|
DetectOutputRedirected = true,
|
||||||
|
};
|
||||||
|
|
||||||
static FloatRect GetSpineObjectBounds(SpineObject sp)
|
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));
|
||||||
sp.Skeleton.GetBounds(out var x, out var y, out var w, out var h);
|
consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Error", NLog.Targets.ConsoleOutputColor.Red, NLog.Targets.ConsoleOutputColor.NoChange));
|
||||||
return new(x, y, Math.Max(w, 1e-6f), Math.Max(h, 1e-6f));
|
consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Fatal", NLog.Targets.ConsoleOutputColor.White, NLog.Targets.ConsoleOutputColor.DarkRed));
|
||||||
}
|
|
||||||
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);
|
|
||||||
|
|
||||||
innerW *= scaleW;
|
config.AddTarget(consoleTarget);
|
||||||
innerH *= scaleH;
|
config.AddRule(LogLevel.Info, LogLevel.Fatal, consoleTarget);
|
||||||
|
LogManager.Configuration = config;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="NLog" Version="5.4.0" />
|
||||||
<PackageReference Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
<PackageReference Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
67
SpineViewerCLI/Utils.cs
Normal file
67
SpineViewerCLI/Utils.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user