feat(cli): Add single-frame image export
Extends the CLI to support exporting single frames as images (.png, .jpg, etc.) in addition to video. The export logic now determines the output type based on the file extension of the `--output` path. - Adds new arguments: `--time` to specify the frame and `--quality` for image compression. - Uses `FrameExporter` for recognized image formats. - Updates the help message with the new options.
This commit is contained in:
@@ -4,31 +4,34 @@ using SFML.Graphics;
|
|||||||
using SFML.System;
|
using SFML.System;
|
||||||
using Spine;
|
using Spine;
|
||||||
using Spine.Exporters;
|
using Spine.Exporters;
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
namespace SpineViewerCLI
|
namespace SpineViewerCLI
|
||||||
{
|
{
|
||||||
public class CLI
|
public class CLI
|
||||||
{
|
{
|
||||||
const string USAGE = @"
|
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] [--width INT] [--height INT] [--centerx INT] [--centery INT] [--zoom FLOAT] [--speed FLOAT] [--color HEX] [--quiet]
|
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:
|
options:
|
||||||
--skel PATH Path to the .skel file
|
--skel PATH Path to the .skel file
|
||||||
--atlas PATH Path to the .atlas file, default searches in the skel file directory
|
--atlas PATH Path to the .atlas file, default searches in the skel file directory
|
||||||
--output PATH Output file path
|
--output PATH Output file path. Extension determines export type (.mp4, .webm for video; .png, .jpg for frame)
|
||||||
--animation STR Animation name
|
--animation STR Animation name
|
||||||
--skin STR Skin name to apply. Can be used multiple times to stack skins.
|
--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.
|
--hide-slot STR Slot name to hide. Can be used multiple times.
|
||||||
--pma Use premultiplied alpha, default false
|
--pma Use premultiplied alpha, default false
|
||||||
--fps INT Frames per second, default 24
|
--fps INT Frames per second (for video), default 24
|
||||||
--loop Whether to loop the animation, default false
|
--loop Whether to loop the animation (for video), default false
|
||||||
--crf INT Constant Rate Factor i.e. video quality, from 0 (lossless) to 51 (worst), default 23
|
--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, default 0
|
||||||
|
--quality INT Quality for lossy image formats (jpg, webp), from 0 to 100, default 80
|
||||||
--width INT Output width, default 512
|
--width INT Output width, default 512
|
||||||
--height INT Output height, default 512
|
--height INT Output height, default 512
|
||||||
--centerx INT Center X offset, default automatically finds bounds
|
--centerx INT Center X offset, default automatically finds bounds
|
||||||
--centery INT Center Y offset, default automatically finds bounds
|
--centery INT Center Y offset, default automatically finds bounds
|
||||||
--zoom FLOAT Zoom level, default 1.0
|
--zoom FLOAT Zoom level, default 1.0
|
||||||
--speed FLOAT Speed of animation, 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)
|
--color HEX Background color as a hex RGBA color, default 000000ff (opaque black)
|
||||||
--quiet Removes console progress log, default false
|
--quiet Removes console progress log, default false
|
||||||
";
|
";
|
||||||
@@ -45,6 +48,8 @@ options:
|
|||||||
uint fps = 24;
|
uint fps = 24;
|
||||||
bool loop = false;
|
bool loop = false;
|
||||||
int crf = 23;
|
int crf = 23;
|
||||||
|
float time = 0f;
|
||||||
|
int quality = 80;
|
||||||
uint? width = null;
|
uint? width = null;
|
||||||
uint? height = null;
|
uint? height = null;
|
||||||
int? centerx = null;
|
int? centerx = null;
|
||||||
@@ -92,6 +97,12 @@ options:
|
|||||||
case "--crf":
|
case "--crf":
|
||||||
crf = int.Parse(args[++i]);
|
crf = int.Parse(args[++i]);
|
||||||
break;
|
break;
|
||||||
|
case "--time":
|
||||||
|
time = float.Parse(args[++i]);
|
||||||
|
break;
|
||||||
|
case "--quality":
|
||||||
|
quality = int.Parse(args[++i]);
|
||||||
|
break;
|
||||||
case "--width":
|
case "--width":
|
||||||
width = uint.Parse(args[++i]);
|
width = uint.Parse(args[++i]);
|
||||||
break;
|
break;
|
||||||
@@ -133,12 +144,7 @@ options:
|
|||||||
Console.Error.WriteLine("Missing --output");
|
Console.Error.WriteLine("Missing --output");
|
||||||
Environment.Exit(2);
|
Environment.Exit(2);
|
||||||
}
|
}
|
||||||
if (!Enum.TryParse<FFmpegVideoExporter.VideoFormat>(Path.GetExtension(output).TrimStart('.'), true, out var videoFormat))
|
var outputExtension = Path.GetExtension(output).TrimStart('.').ToLowerInvariant();
|
||||||
{
|
|
||||||
var validExtensions = string.Join(", ", Enum.GetNames(typeof(FFmpegVideoExporter.VideoFormat)));
|
|
||||||
Console.Error.WriteLine($"Invalid output extension. Supported formats are: {validExtensions}");
|
|
||||||
Environment.Exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
var sp = new SpineObject(skelPath, atlasPath);
|
var sp = new SpineObject(skelPath, atlasPath);
|
||||||
sp.UsePma = pma;
|
sp.UsePma = pma;
|
||||||
@@ -163,13 +169,16 @@ options:
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(animation))
|
if (string.IsNullOrEmpty(animation))
|
||||||
{
|
{
|
||||||
var availableAnimations = string.Join(", ", sp.Data.Animations);
|
var availableAnimations = string.Join(", ", sp.Data.Animations.Select(a => a.Name));
|
||||||
Console.Error.WriteLine($"Missing --animation. Available animations for {sp.Name}: {availableAnimations}");
|
Console.Error.WriteLine($"Missing --animation. Available animations for {sp.Name}: {availableAnimations}");
|
||||||
Environment.Exit(2);
|
Environment.Exit(2);
|
||||||
}
|
}
|
||||||
var trackEntry = sp.AnimationState.SetAnimation(0, animation, loop);
|
var trackEntry = sp.AnimationState.SetAnimation(0, animation, loop);
|
||||||
|
trackEntry.TrackTime = time;
|
||||||
sp.Update(0);
|
sp.Update(0);
|
||||||
|
|
||||||
|
if (Enum.TryParse<FFmpegVideoExporter.VideoFormat>(outputExtension, true, out var videoFormat))
|
||||||
|
{
|
||||||
FFmpegVideoExporter exporter;
|
FFmpegVideoExporter exporter;
|
||||||
if (width is uint w && height is uint h && centerx is int cx && centery is int cy)
|
if (width is uint w && height is uint h && centerx is int cx && centery is int cy)
|
||||||
{
|
{
|
||||||
@@ -203,11 +212,76 @@ options:
|
|||||||
exporter.Export(output, cts.Token, sp);
|
exporter.Export(output, cts.Token, sp);
|
||||||
|
|
||||||
if (!quiet)
|
if (!quiet)
|
||||||
Console.WriteLine();
|
Console.WriteLine("\nVideo export complete.");
|
||||||
|
}
|
||||||
|
else if (TryGetImageFormat(outputExtension, out var imageFormat))
|
||||||
|
{
|
||||||
|
if (!quiet) Console.WriteLine($"Exporting single frame at {time: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 validVideoExtensions = string.Join(", ", Enum.GetNames(typeof(FFmpegVideoExporter.VideoFormat)));
|
||||||
|
var validImageExtensions = "png, jpg, jpeg, webp, bmp";
|
||||||
|
Console.Error.WriteLine($"Invalid output extension. Supported video formats are: {validVideoExtensions}. Supported image formats are: {validImageExtensions}.");
|
||||||
|
Environment.Exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
Environment.Exit(0);
|
Environment.Exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryGetImageFormat(string extension, out SKEncodedImageFormat format)
|
||||||
|
{
|
||||||
|
switch (extension)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public static SpineObject CopySpineObject(SpineObject sp)
|
public static SpineObject CopySpineObject(SpineObject sp)
|
||||||
{
|
{
|
||||||
var spineObject = new SpineObject(sp, true);
|
var spineObject = new SpineObject(sp, true);
|
||||||
|
|||||||
Reference in New Issue
Block a user