Editor/SuperShotSettings.cs

Editor settings and data models for the SuperShot tool. Defines enums for resolutions, formats, filters and anchors, and classes that hold capture, edit, output, sharing (Discord webhook) and preset settings, plus load/save logic for persistent settings.

File AccessHttp Calls
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json.Serialization;
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( "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( "Show Game UI & Widgets" ), Description( "Include screen/world UI panels and PanelComponent widgets in captures. Turn this off for clean artwork shots." )]
	public bool ShowGameUI { get; set; } = true;

	[Hide]
	public bool HideUI { get; set; } = false;

	[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( "Default Filter" ), Description( "Filter preset applied to newly captured shots" )]
	public string DefaultFilterPresetId { get; set; } = "";

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

	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 DateTime? LastPostedUtc { get; set; }

	public bool IsConfigured => !string.IsNullOrWhiteSpace( Url );

	[JsonIgnore]
	public string LastError { get; set; } = "";

	[JsonIgnore]
	public bool LastPostFailed { get; set; } = false;
}

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( "Bot Identity (optional)" ), Title( "Bot Username" ), Description( "Override the webhook's display name (optional)" )]
	public string Username { get; set; } = "";

	[Property, Group( "Bot Identity (optional)" ), Title( "Bot Avatar URL" ), Description( "Override the webhook's avatar image (optional)" )]
	public string AvatarUrl { get; set; } = "";

	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 string LastSelectedImagePath { get; set; } = "";
}

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 List<FilterPreset> FilterPresets { 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.EnsureBuiltinFilterPresets();

					return loaded;
				}
			}
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Supershot] Failed to load settings: {e.Message}" );
		}

		var fresh = new SuperShotSettings();
		fresh.EnsureBuiltinPresets();
		fresh.EnsureBuiltinFilterPresets();
		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( "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 );
	}

	public void EnsureBuiltinFilterPresets()
	{
		FilterPresets ??= new List<FilterPreset>();

		void Add( string id, string name, Action<EditSettings> configure )
		{
			var existing = FilterPresets.Find( p => p?.Id == id );
			if ( existing is not null )
			{
				existing.BuiltIn = true;
				existing.PhotoEditSettings ??= new EditSettings();
				existing.PostFx ??= new PostFxSettings();
				return;
			}

			var edit = new EditSettings();
			configure?.Invoke( edit );
			FilterPresets.Add( new FilterPreset
			{
				Id = id,
				Name = name,
				BuiltIn = true,
				UseForCapture = id != "none",
				UseForGameCamera = true,
				PhotoEditSettings = edit,
				PostFx = new PostFxSettings()
			} );
		}

		Add( "none", "None", null );
		Add( "warm", "Warm", e => { e.Exposure = 0.05f; e.Contrast = 1.05f; e.Saturation = 1.10f; } );
		Add( "cool", "Cool", e => { e.Contrast = 1.05f; e.Saturation = 1.05f; e.Filter = ShotFilter.Cool; } );
		Add( "vintage", "Vintage", e => { e.Contrast = 0.92f; e.Saturation = 0.85f; e.Vignette = 0.35f; e.Grain = 0.15f; } );
		Add( "cinematic", "Cinematic", e => { e.Contrast = 1.15f; e.Saturation = 1.05f; e.Vignette = 0.25f; } );
		Add( "black-white", "Black & White", e => { e.Contrast = 1.10f; e.Saturation = 0f; } );
		Add( "high-contrast", "High Contrast", e => { e.Contrast = 1.35f; e.Saturation = 1.05f; } );
	}

	public FilterPreset FindFilterPreset( string id )
	{
		EnsureBuiltinFilterPresets();
		if ( string.IsNullOrEmpty( id ) )
			return null;

		return FilterPresets.Find( p => p?.Id == id );
	}
}