Editor settings classes for the SuperShot tool. Defines enums and POCOs for capture, edit, output and sharing options, loads/saves a JSON settings file under AppData, migrates some legacy values and provides builtin presets.
using System;
using System.Collections.Generic;
using System.IO;
using Sandbox;
namespace Editor.SuperShot;
public enum ShotResolution
{
[Title( "1K (1024x576)" )] OneK,
[Title( "720p (1280x720)" )] HD720,
[Title( "1080p (1920x1080)" )] HD1080,
[Title( "1440p (2560x1440)" )] QHD1440,
[Title( "4K (3840x2160)" )] UHD4K,
[Title( "5K (5120x2880)" )] UHD5K,
[Title( "8K (7680x4320)" )] UHD8K,
[Title( "Square 1080 (1080x1080)" )] Square1080,
[Title( "Square 4K (3840x3840)" )] Square4K,
[Title( "Portrait (1080x1920)" )] Portrait,
[Title( "Ultrawide 1440 (3440x1440)" )] Ultrawide1440,
[Title( "Ultrawide 1080 (2560x1080)" )] Ultrawide1080,
[Title( "Cinematic 21:9 (3840x1646)" )] Cinematic,
[Title( "YouTube Thumbnail (1280x720)" )] YouTubeThumbnail,
[Title( "s&box Package - Square (512x512)" )] PackageSquare,
[Title( "s&box Package - Wide (910x512)" )] PackageWide,
[Title( "s&box Package - Tall (512x910)" )] PackageTall,
[Title( "Custom" )] Custom
}
public enum ShotFormat
{
[Title( "PNG" )] Png,
[Title( "JPG" )] Jpg,
[Title( "WebP" )] WebP
}
public enum ShotFilter
{
[Title( "None" )] None,
[Title( "Warm" )] Warm,
[Title( "Cool" )] Cool,
[Title( "Vintage" )] Vintage,
[Title( "Cinematic" )] Cinematic,
[Title( "Black & White" )] BlackAndWhite,
[Title( "High Contrast" )] HighContrast
}
public enum ShotAnchor
{
[Title( "Top Left" )] TopLeft,
[Title( "Top Right" )] TopRight,
[Title( "Bottom Left" )] BottomLeft,
[Title( "Bottom Right" )] BottomRight,
[Title( "Center" )] Center
}
public enum ShotRotate
{
[Title( "0\u00b0" )] None,
[Title( "90\u00b0" )] Cw90,
[Title( "180\u00b0" )] Half,
[Title( "270\u00b0" )] Ccw90
}
public enum CaptureSource
{
[Title( "Editor Freecam" )] EditorFreecam,
[Title( "Scene Main Camera" )] SceneMainCamera
}
public sealed class CaptureSettings
{
[Property, Title( "Source" ), Description( "Editor freecam (default) or the scene's main camera" )]
public CaptureSource Source { get; set; } = CaptureSource.EditorFreecam;
[Property, Title( "Resolution" )]
public ShotResolution Resolution { get; set; } = ShotResolution.UHD4K;
[Property, Title( "Custom Width" ), Range( 64, 16384 ), ShowIf( nameof( Resolution ), ShotResolution.Custom )]
public int CustomWidth { get; set; } = 1920;
[Property, Title( "Custom Height" ), Range( 64, 16384 ), ShowIf( nameof( Resolution ), ShotResolution.Custom )]
public int CustomHeight { get; set; } = 1080;
[Property, Title( "Super Sampling" ), Range( 1f, 4f ), Description( "Render larger then downscale for crisper anti-aliasing" )]
public float SuperSampling { get; set; } = 1f;
[Property, Title( "Override FOV" ), Description( "Only affects the Scene Main Camera source - freecam shots always use the editor 3D view's FOV" ), ShowIf( nameof( Source ), CaptureSource.SceneMainCamera )]
public bool OverrideFov { get; set; } = false;
[Property, Title( "Field of View" ), Range( 10f, 130f ), ShowIf( nameof( OverrideFov ), true )]
public float Fov { get; set; } = 70f;
[Property, Title( "Transparent Background" ), Description( "Render with an alpha channel (PNG only) for cut-out artwork" )]
public bool TransparentBackground { get; set; } = false;
[Property, Title( "Hide UI" ), Description( "Disable screen/world UI panels during the shot" )]
public bool HideUI { get; set; } = true;
[Property, Title( "Hide Tags" ), Description( "Comma separated object tags to hide for the shot" )]
public string HideTags { get; set; } = "";
[Property, Title( "Capture Delay" ), Range( 0f, 10f ), Description( "Seconds to wait before capturing" )]
public float Delay { get; set; } = 0f;
[Property, Title( "Engine Post FX" ), Description( "Real engine post-process applied to the camera at capture time (Bloom, DOF, Tonemapping, ...)" )]
public PostFxSettings PostFx { get; set; } = new();
}
public sealed class EditSettings
{
[Property, Group( "Color" ), Title( "Brightness" ), Range( 0f, 2f )]
public float Brightness { get; set; } = 1f;
[Property, Group( "Color" ), Title( "Contrast" ), Range( 0f, 2f )]
public float Contrast { get; set; } = 1f;
[Property, Group( "Color" ), Title( "Saturation" ), Range( 0f, 2f )]
public float Saturation { get; set; } = 1f;
[Property, Group( "Color" ), Title( "Exposure" ), Range( -1f, 1f )]
public float Exposure { get; set; } = 0f;
[Property, Group( "Color" ), Title( "Hue Shift" ), Range( -180f, 180f )]
public float Hue { get; set; } = 0f;
[Property, Group( "Color" ), Title( "Filter Preset" )]
public ShotFilter Filter { get; set; } = ShotFilter.None;
[Property, Group( "Detail" ), Title( "Sharpen" ), Range( 0f, 2f )]
public float Sharpen { get; set; } = 0f;
[Property, Group( "Detail" ), Title( "Blur" ), Range( 0f, 20f )]
public float Blur { get; set; } = 0f;
[Property, Group( "Effects" ), Title( "Vignette" ), Range( 0f, 1f )]
public float Vignette { get; set; } = 0f;
[Property, Group( "Effects" ), Title( "Film Grain" ), Range( 0f, 1f )]
public float Grain { get; set; } = 0f;
[Property, Group( "Transform" ), Title( "Rotate" )]
public ShotRotate Rotate { get; set; } = ShotRotate.None;
[Property, Group( "Transform" ), Title( "Flip Horizontal" )]
public bool FlipH { get; set; } = false;
[Property, Group( "Transform" ), Title( "Flip Vertical" )]
public bool FlipV { get; set; } = false;
[Property, Group( "Frame" ), Title( "Border Size" ), Range( 0, 256 )]
public int Border { get; set; } = 0;
[Property, Group( "Frame" ), Title( "Border Color" )]
public Color BorderColor { get; set; } = Color.White;
[Property, Group( "Watermark" ), Title( "Enable Watermark" )]
public bool Watermark { get; set; } = false;
[Property, Group( "Watermark" ), Title( "Text" ), ShowIf( nameof( Watermark ), true )]
public string WatermarkText { get; set; } = "";
[Property, Group( "Watermark" ), Title( "Anchor" ), ShowIf( nameof( Watermark ), true )]
public ShotAnchor WatermarkAnchor { get; set; } = ShotAnchor.BottomRight;
[Property, Group( "Watermark" ), Title( "Size" ), Range( 8f, 200f ), ShowIf( nameof( Watermark ), true )]
public float WatermarkSize { get; set; } = 48f;
[Property, Group( "Watermark" ), Title( "Color" ), ShowIf( nameof( Watermark ), true )]
public Color WatermarkColor { get; set; } = Color.White.WithAlpha( 0.85f );
public EditSettings Clone()
{
return (EditSettings)MemberwiseClone();
}
public bool IsDefault()
{
var d = new EditSettings();
return Brightness == d.Brightness && Contrast == d.Contrast && Saturation == d.Saturation
&& Exposure == d.Exposure && Hue == d.Hue && Filter == d.Filter && Sharpen == d.Sharpen
&& Blur == d.Blur && Vignette == d.Vignette && Grain == d.Grain && Rotate == d.Rotate
&& !FlipH && !FlipV && Border == d.Border && !Watermark;
}
}
public sealed class OutputSettings
{
[Property, Title( "Output Folder" ), Description( "Where saved images are written. Leave empty to auto-save into a folder named after the current project." )]
public string OutputFolder { get; set; } = "";
[Property, Title( "Format" )]
public ShotFormat Format { get; set; } = ShotFormat.Png;
[Property, Title( "JPG / WebP Quality" ), Range( 1, 100 ), ShowIf( nameof( Format ), ShotFormat.Jpg )]
public int Quality { get; set; } = 95;
[Property, Title( "Filename Template" ), Description( "Tokens: {scene} {project} {resolution} {w} {h} {date} {time} {counter}" )]
public string FilenameTemplate { get; set; } = "{scene}_{date}_{time}";
[Property, Title( "Open Folder After Save" )]
public bool OpenFolderAfterSave { get; set; } = false;
public string ResolveFolder()
{
if ( !string.IsNullOrWhiteSpace( OutputFolder ) && !IsLegacyDefault( OutputFolder ) )
return OutputFolder;
return DefaultFolder();
}
public static string DefaultFolder()
{
return Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.MyPictures ), CurrentProjectName() );
}
static string CurrentProjectName()
{
try
{
var title = Project.Current?.Config?.Title;
if ( !string.IsNullOrWhiteSpace( title ) )
return SanitizeFolderName( title );
}
catch
{
}
return "Supershot";
}
public void MigrateLegacy()
{
if ( IsLegacyDefault( OutputFolder ) )
OutputFolder = "";
}
static bool IsLegacyDefault( string folder )
{
try
{
var legacy = Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.MyPictures ), "High Rollers" );
return string.Equals( Path.GetFullPath( folder ), Path.GetFullPath( legacy ), StringComparison.OrdinalIgnoreCase );
}
catch
{
return false;
}
}
static string SanitizeFolderName( string input )
{
foreach ( var c in Path.GetInvalidFileNameChars() )
input = input.Replace( c, '_' );
return input.Trim();
}
}
public sealed class DiscordWebhookConfig
{
[Property, Title( "Channel Name" )]
public string Name { get; set; } = "New Channel";
[Property, Title( "Webhook URL" ), Description( "Discord channel webhook URL (Channel Settings > Integrations > Webhooks)" )]
public string Url { get; set; } = "";
[Property, Title( "Include in Post to All" ), Description( "When on, this channel receives 'Post to All' broadcasts" )]
public bool Enabled { get; set; } = true;
public bool Favorite { get; set; } = false;
public WebhookMessageMode MessageMode { get; set; } = WebhookMessageMode.Shared;
// Legacy flag kept only so older settings files can migrate into MessageMode.
public bool UseCustomMessage { get; set; } = false;
public DiscordMessage CustomMessage { get; set; } = new();
public bool IsConfigured => !string.IsNullOrWhiteSpace( Url );
public DiscordMessage ResolveMessage( DiscordMessage shared )
{
return MessageMode switch
{
WebhookMessageMode.Custom => CustomMessage ?? shared,
WebhookMessageMode.None => null,
_ => shared,
};
}
public void MigrateMessageMode()
{
if ( UseCustomMessage && MessageMode == WebhookMessageMode.Shared )
MessageMode = WebhookMessageMode.Custom;
UseCustomMessage = false;
}
}
public enum WebhookMessageMode
{
Shared,
Custom,
None,
}
public sealed class DiscordMessage
{
[Property, Group( "Message" ), Title( "Message Text" ), Description( "Plain text posted above the image (supports Discord markdown)" ), TextArea]
public string Content { get; set; } = "";
[Property, Group( "Message" ), Title( "Bot Username" ), Description( "Override the webhook's display name (optional)" )]
public string Username { get; set; } = "";
[Property, Group( "Message" ), Title( "Bot Avatar URL" ), Description( "Override the webhook's avatar image (optional)" )]
public string AvatarUrl { get; set; } = "";
[Property, Group( "Embed" ), Title( "Use Rich Embed" ), Description( "Wrap the image in a colored embed card" )]
public bool UseEmbed { get; set; } = true;
[Property, Group( "Embed" ), Title( "Title" ), ShowIf( nameof( UseEmbed ), true )]
public string EmbedTitle { get; set; } = "";
[Property, Group( "Embed" ), Title( "Description" ), TextArea, ShowIf( nameof( UseEmbed ), true )]
public string EmbedDescription { get; set; } = "";
[Property, Group( "Embed" ), Title( "Accent Color" ), ShowIf( nameof( UseEmbed ), true )]
public Color EmbedColor { get; set; } = new Color( 0.35f, 0.55f, 0.92f );
[Property, Group( "Embed" ), Title( "Footer" ), ShowIf( nameof( UseEmbed ), true )]
public string EmbedFooter { get; set; } = "";
[Property, Group( "Embed" ), Title( "Show Timestamp" ), ShowIf( nameof( UseEmbed ), true )]
public bool EmbedTimestamp { get; set; } = true;
public DiscordMessage Clone() => (DiscordMessage)MemberwiseClone();
}
public sealed class ShareSettings
{
[Property, Title( "Channels" ), Description( "Saved Discord channel webhooks" )]
public List<DiscordWebhookConfig> Webhooks { get; set; } = new();
[Property, Title( "Message" ), Description( "How posted screenshots are formatted" )]
public DiscordMessage Message { get; set; } = new();
}
public sealed class ShotPreset
{
public string Name { get; set; } = "Preset";
public CaptureSettings Capture { get; set; } = new();
public EditSettings Edit { get; set; } = new();
public ShotFormat Format { get; set; } = ShotFormat.Png;
}
public sealed class SuperShotSettings
{
public CaptureSettings Capture { get; set; } = new();
public EditSettings Edit { get; set; } = new();
public OutputSettings Output { get; set; } = new();
public ShareSettings Share { get; set; } = new();
public List<ShotPreset> Presets { get; set; } = new();
public bool LivePreview { get; set; } = true;
static string SettingsPath()
{
var dir = Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.ApplicationData ), "sbox-supershot" );
Directory.CreateDirectory( dir );
return Path.Combine( dir, "settings.json" );
}
static SuperShotSettings _current;
public static SuperShotSettings Current => _current ??= Load();
public static SuperShotSettings Load()
{
try
{
var path = SettingsPath();
if ( File.Exists( path ) )
{
var json = File.ReadAllText( path );
var loaded = Json.Deserialize<SuperShotSettings>( json );
if ( loaded is not null )
{
loaded.EnsureBuiltinPresets();
loaded.Output?.MigrateLegacy();
if ( loaded.Share?.Webhooks != null )
{
foreach ( var wh in loaded.Share.Webhooks )
wh?.MigrateMessageMode();
}
return loaded;
}
}
}
catch ( Exception e )
{
Log.Warning( $"[Supershot] Failed to load settings: {e.Message}" );
}
var fresh = new SuperShotSettings();
fresh.EnsureBuiltinPresets();
return fresh;
}
public void Save()
{
try
{
File.WriteAllText( SettingsPath(), Json.Serialize( this ) );
}
catch ( Exception e )
{
Log.Warning( $"[Supershot] Failed to save settings: {e.Message}" );
}
}
void EnsureBuiltinPresets()
{
void Add( string name, ShotResolution res, ShotFormat fmt, ShotFilter filter = ShotFilter.None )
{
if ( Presets.Exists( p => p.Name == name ) )
return;
var preset = new ShotPreset { Name = name, Format = fmt };
preset.Capture.Resolution = res;
preset.Edit.Filter = filter;
Presets.Add( preset );
}
Add( "YouTube Thumbnail", ShotResolution.YouTubeThumbnail, ShotFormat.Png );
Add( "Discord Art", ShotResolution.HD1080, ShotFormat.Png );
Add( "Steam 4K", ShotResolution.UHD4K, ShotFormat.Jpg );
Add( "s&box Package Square", ShotResolution.PackageSquare, ShotFormat.Png );
Add( "s&box Package Wide", ShotResolution.PackageWide, ShotFormat.Png );
Add( "s&box Package Tall", ShotResolution.PackageTall, ShotFormat.Png );
}
}