Editor/SuperShotService.cs

Editor-side service for taking, encoding, saving and sharing screenshots (SuperShot). It captures bitmaps, applies edits, builds filenames, saves/overwrites files, copies paths to clipboard, opens folders/URLs, posts images to Discord via a webhook helper, and handles package thumbnail presets.

File AccessHttp Calls
🌐 https://steamcommunity.com/sharedfiles/edititem/590830/3/
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Sandbox;

namespace Editor.SuperShot;

public static class SuperShotService
{
	static int _sessionCounter = 1;

	public static Bitmap CaptureRaw( CaptureSettings capture )
	{
		return SuperShotCapture.Capture( capture );
	}

	public static Bitmap CaptureFinished( CaptureSettings capture, EditSettings edit )
	{
		var raw = CaptureRaw( capture );
		if ( raw is null )
			return null;

		if ( edit is null || edit.IsDefault() )
			return raw;

		var finished = SuperShotEdit.Apply( raw, edit );
		raw.Dispose();
		return finished;
	}

	public static string Extension( ShotFormat format ) => format switch
	{
		ShotFormat.Jpg => "jpg",
		ShotFormat.WebP => "webp",
		_ => "png"
	};

	public static string MimeType( ShotFormat format ) => format switch
	{
		ShotFormat.Jpg => "image/jpeg",
		ShotFormat.WebP => "image/webp",
		_ => "image/png"
	};

	public static byte[] Encode( Bitmap bitmap, ShotFormat format, int quality )
	{
		return format switch
		{
			ShotFormat.Jpg => bitmap.ToJpg( quality ),
			ShotFormat.WebP => bitmap.ToWebP( quality ),
			_ => bitmap.ToPng()
		};
	}

	public static bool IsPackageThumbnail( ShotResolution resolution )
	{
		return resolution is ShotResolution.PackageSquare
			or ShotResolution.PackageWide
			or ShotResolution.PackageTall;
	}

	public static string PackageThumbnailPrefix( ShotResolution resolution ) => resolution switch
	{
		ShotResolution.PackageSquare => "sbox_square_",
		ShotResolution.PackageWide => "sbox_wide_",
		ShotResolution.PackageTall => "sbox_tall_",
		_ => null
	};

	public static string BuildFilename( OutputSettings output, CaptureSettings capture = null )
	{
		var scene = SuperShotContext.ActiveScene?.Name;
		if ( string.IsNullOrWhiteSpace( scene ) )
			scene = "scene";
		scene = Sanitize( scene );

		var project = CurrentProjectName();

		int w = 0, h = 0;
		if ( capture is not null )
			(w, h) = SuperShotContext.ResolveSize( capture );

		var now = DateTime.Now;
		var name = (output.FilenameTemplate ?? "{scene}_{date}_{time}")
			.Replace( "{scene}", scene )
			.Replace( "{project}", project )
			.Replace( "{resolution}", w > 0 ? $"{w}x{h}" : "" )
			.Replace( "{w}", w > 0 ? w.ToString() : "" )
			.Replace( "{h}", h > 0 ? h.ToString() : "" )
			.Replace( "{date}", now.ToString( "yyyy-MM-dd" ) )
			.Replace( "{time}", now.ToString( "HH-mm-ss" ) )
			.Replace( "{counter}", (_sessionCounter++).ToString( "D3" ) );

		name = Sanitize( name ).Trim( '_', ' ', '-' );
		if ( string.IsNullOrWhiteSpace( name ) )
			name = scene;

		if ( capture is not null && PackageThumbnailPrefix( capture.Resolution ) is { } prefix )
			name = prefix + name;

		return $"{name}.{Extension( output.Format )}";
	}

	static string CurrentProjectName()
	{
		try
		{
			var title = Project.Current?.Config?.Title;
			if ( !string.IsNullOrWhiteSpace( title ) )
				return Sanitize( title ).Trim();
		}
		catch { }

		return "project";
	}

	public static string Save( Bitmap bitmap, OutputSettings output, CaptureSettings capture = null )
	{
		if ( bitmap is null )
			return null;

		try
		{
			var folder = output.ResolveFolder();
			Directory.CreateDirectory( folder );

			var path = Path.Combine( folder, BuildFilename( output, capture ) );
			File.WriteAllBytes( path, Encode( bitmap, output.Format, output.Quality ) );
			Log.Info( $"[Supershot] Saved {path} ({bitmap.Width}x{bitmap.Height})" );

			if ( output.OpenFolderAfterSave )
				RevealInExplorer( path );

			return path;
		}
		catch ( Exception e )
		{
			Log.Error( $"[Supershot] Save failed: {e.Message}" );
			return null;
		}
	}

	// Overwrite an existing file in place, keeping its extension's format and the bitmap's current resolution.
	public static string SaveOver( Bitmap bitmap, string path, int quality )
	{
		if ( bitmap is null || string.IsNullOrEmpty( path ) )
			return null;

		try
		{
			var format = FormatFromExtension( path );
			File.WriteAllBytes( path, Encode( bitmap, format, quality ) );
			Log.Info( $"[Supershot] Overwrote {path} ({bitmap.Width}x{bitmap.Height})" );
			return path;
		}
		catch ( Exception e )
		{
			Log.Error( $"[Supershot] Overwrite failed: {e.Message}" );
			return null;
		}
	}

	public static ShotFormat FormatFromExtension( string path )
	{
		return Path.GetExtension( path ).ToLowerInvariant() switch
		{
			".jpg" or ".jpeg" => ShotFormat.Jpg,
			".webp" => ShotFormat.WebP,
			_ => ShotFormat.Png
		};
	}

	// Capture+save Square, Wide and Tall package thumbnails via the clean QuickCapture path (no photo-edit pass).
	public static int CaptureAllPackageThumbnails()
	{
		ShotResolution[] set = { ShotResolution.PackageSquare, ShotResolution.PackageWide, ShotResolution.PackageTall };

		int saved = 0;
		foreach ( var res in set )
		{
			var path = QuickCapture( res );
			if ( path is not null )
				saved++;
		}

		Log.Info( $"[Supershot] Captured {saved}/{set.Length} package thumbnails." );
		return saved;
	}

	public static bool CopyPathToClipboard( string path )
	{
		if ( string.IsNullOrEmpty( path ) )
		{
			Log.Warning( "[Supershot] Nothing saved yet to copy a path for." );
			return false;
		}

		try
		{
			EditorUtility.Clipboard.Copy( path );
			Log.Info( $"[Supershot] Copied path to clipboard: {path}" );
			return true;
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Supershot] Could not copy to clipboard: {e.Message}" );
			return false;
		}
	}

	static CaptureSettings CloneCaptureWithResolution( CaptureSettings source, ShotResolution resolution )
	{
		return new CaptureSettings
		{
			Source = source.Source,
			Resolution = resolution,
			SuperSampling = source.SuperSampling,
			OverrideFov = source.OverrideFov,
			Fov = source.Fov,
			TransparentBackground = source.TransparentBackground,
			HideUI = source.HideUI,
			HideTags = source.HideTags,
			Delay = source.Delay,
			PostFx = source.PostFx
		};
	}

	public static string CaptureAndSave( CaptureSettings capture, EditSettings edit, OutputSettings output )
	{
		using var bitmap = CaptureFinished( capture, edit );
		if ( bitmap is null )
			return null;

		return Save( bitmap, output, capture );
	}

	// Fast local save at a resolution preset. Never posts to Discord (use the explicit Capture + Post actions).
	public static string QuickCapture( ShotResolution resolution )
	{
		var settings = SuperShotSettings.Current;
		var capture = CloneCaptureWithResolution( settings.Capture, resolution );

		return CaptureAndSave( capture, null, settings.Output );
	}

	public static async Task CaptureAndPostAll( string messageOverride = null )
	{
		var settings = SuperShotSettings.Current;
		var path = CaptureAndSave( settings.Capture, settings.Edit, settings.Output );
		if ( path is null )
			return;

		var targets = EnabledWebhooks( settings.Share );
		if ( targets.Count == 0 )
		{
			Log.Warning( "[Supershot] No enabled Discord channels configured (open the Studio Discord tab)." );
			return;
		}

		var message = WithMessageOverride( settings.Share.Message, messageOverride );
		var bytes = File.ReadAllBytes( path );
		int sent = await DiscordWebhook.PostImageToMany( targets, bytes, Path.GetFileName( path ), message, MimeType( settings.Output.Format ) );
		Log.Info( $"[Supershot] Posted to {sent}/{targets.Count} Discord channel(s)." );
	}

	static DiscordMessage WithMessageOverride( DiscordMessage shared, string overrideContent )
	{
		if ( string.IsNullOrWhiteSpace( overrideContent ) )
			return shared;

		var message = shared?.Clone() ?? new DiscordMessage();
		message.Content = overrideContent.Trim();
		return message;
	}

	public static async Task CaptureAndPostTo( DiscordWebhookConfig webhook )
	{
		if ( webhook is null || !webhook.IsConfigured )
		{
			Log.Warning( "[Supershot] That channel has no webhook URL set." );
			return;
		}

		var settings = SuperShotSettings.Current;
		var path = CaptureAndSave( settings.Capture, settings.Edit, settings.Output );
		if ( path is null )
			return;

		var bytes = File.ReadAllBytes( path );
		if ( await DiscordWebhook.PostImage( webhook.Url, bytes, Path.GetFileName( path ), webhook.ResolveMessage( settings.Share.Message ), MimeType( settings.Output.Format ) ) )
			Log.Info( $"[Supershot] Posted to '{webhook.Name}'." );
	}

	public static List<DiscordWebhookConfig> EnabledWebhooks( ShareSettings share )
	{
		var result = new List<DiscordWebhookConfig>();
		if ( share?.Webhooks is null )
			return result;

		foreach ( var wh in share.Webhooks )
		{
			if ( wh is not null && wh.Enabled && wh.IsConfigured )
				result.Add( wh );
		}
		return result;
	}

	public static void RevealInExplorer( string path )
	{
		try
		{
			if ( string.IsNullOrEmpty( path ) )
				return;

			if ( Directory.Exists( path ) )
				EditorUtility.OpenFolder( path );
			else
				EditorUtility.OpenFileFolder( path );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Supershot] Could not open folder: {e.Message}" );
		}
	}

	// Steam Community artwork upload page for s&box (appid 590830).
	public const string SboxArtPageUrl = "https://steamcommunity.com/sharedfiles/edititem/590830/3/";

	public static void OpenSboxArtPage()
	{
		try
		{
			EditorUtility.OpenFile( SboxArtPageUrl );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Supershot] Could not open the s&box art page: {e.Message}" );
		}
	}

	public static void CaptureAndUploadToArtPage()
	{
		var settings = SuperShotSettings.Current;
		var path = CaptureAndSave( settings.Capture, settings.Edit, settings.Output );
		UploadToArtPage( path );
	}

	public static void UploadToArtPage( string savedPath )
	{
		if ( !string.IsNullOrEmpty( savedPath ) )
		{
			CopyPathToClipboard( savedPath );
			Log.Info( "[Supershot] Saved shot path copied to clipboard - paste it into Steam's upload file picker." );
		}
		else
		{
			Log.Warning( "[Supershot] Nothing saved to upload - capture a shot first." );
		}

		OpenSboxArtPage();
	}

	static string Sanitize( string input )
	{
		foreach ( var c in Path.GetInvalidFileNameChars() )
			input = input.Replace( c, '_' );
		return input;
	}
}