Editor/SuperShotSettings.cs

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.

File AccessHttp Calls
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 );
	}
}