Files
SpineViewer/SpineViewerCLI/ExportCommand.cs
2025-10-26 16:16:43 +08:00

464 lines
17 KiB
C#

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