Editor window for the Supershot Studio tool. Manages capturing the scene view to bitmaps, editing settings, an in-memory gallery, saving to disk, posting to Discord webhooks, undo/redo for edits, and constructing the editor UI layout and menus.
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Sandbox;
namespace Editor.SuperShot;
public sealed class CapturedShot
{
public Bitmap Raw { get; set; }
public EditSettings Edit { get; set; }
public Pixmap Thumbnail { get; set; }
public string SavedPath { get; set; }
public Vector2Int Size { get; set; }
public DateTime Time { get; set; } = DateTime.Now;
}
[EditorApp( "Supershot Studio", "photo_camera", "Take and edit screenshots from the editor camera" )]
public sealed class SuperShotWindow : DockWindow
{
public SuperShotSettings Settings => SuperShotSettings.Current;
public Bitmap RawCapture { get; private set; }
public bool HasCapture => RawCapture is not null && RawCapture.IsValid;
public List<CapturedShot> Gallery { get; } = new();
public event Action Changed;
readonly List<EditSettings> _undo = new();
readonly List<EditSettings> _redo = new();
EditSettings _editBaseline;
bool _reviewingGalleryShot;
CapturedShot _editingShot;
static SuperShotWindow _instance;
bool _duplicate;
public static SuperShotWindow Open()
{
if ( _instance is not null && _instance.IsValid )
{
_instance.Show();
_instance.Focus();
return _instance;
}
return new SuperShotWindow();
}
public SuperShotWindow()
{
// The editor "Apps" launcher news this up directly, bypassing Open(); a duplicate focuses the real
// window and discards itself in Show().
if ( _instance is not null && _instance.IsValid )
{
_duplicate = true;
return;
}
_instance = this;
DeleteOnClose = true;
Title = "Supershot Studio";
Size = new Vector2( 1280, 820 );
SetWindowIcon( "photo_camera" );
LoadSavedShotsIntoGallery();
RestoreDefaultDockLayout();
Show();
}
public override void Show()
{
if ( _duplicate )
{
if ( _instance is not null && _instance.IsValid )
{
_instance.Show();
_instance.Focus();
}
Destroy();
return;
}
base.Show();
}
public void NotifyChanged() => Changed?.Invoke();
public void CaptureNow()
{
var raw = SuperShotService.CaptureRaw( Settings.Capture );
if ( raw is null )
{
Log.Warning( "[Supershot] Capture produced no image. Move the scene view (activate the Supershot framing tool) or pick a scene camera." );
return;
}
RawCapture?.Dispose();
RawCapture = raw;
_reviewingGalleryShot = false;
_editingShot = null;
_editBaseline = Settings.Edit.Clone();
AddToGallery( raw, Settings.Edit );
NotifyChanged();
}
public string Capture()
{
CaptureNow();
if ( !HasCapture )
return null;
var path = SaveCurrent();
if ( path is not null && Gallery.Count > 0 )
_editingShot = Gallery[^1];
return path;
}
public void QuickCapture( ShotResolution resolution )
{
var previous = Settings.Capture.Resolution;
Settings.Capture.Resolution = resolution;
CaptureNow();
SaveCurrent();
Settings.Capture.Resolution = previous;
NotifyChanged();
}
public void CaptureAt( ShotResolution resolution )
{
var previous = Settings.Capture.Resolution;
Settings.Capture.Resolution = resolution;
CaptureNow();
Settings.Capture.Resolution = previous;
NotifyChanged();
}
public Bitmap BuildFinished()
{
if ( !HasCapture )
return null;
return SuperShotEdit.Apply( RawCapture, Settings.Edit );
}
public void PushEditUndo()
{
_undo.Add( Settings.Edit.Clone() );
if ( _undo.Count > 64 ) _undo.RemoveAt( 0 );
_redo.Clear();
}
public void Undo()
{
if ( _undo.Count == 0 ) return;
_redo.Add( Settings.Edit.Clone() );
var prev = _undo[^1];
_undo.RemoveAt( _undo.Count - 1 );
CopyEdit( prev, Settings.Edit );
Settings.Save();
NotifyChanged();
}
public void Redo()
{
if ( _redo.Count == 0 ) return;
_undo.Add( Settings.Edit.Clone() );
var next = _redo[^1];
_redo.RemoveAt( _redo.Count - 1 );
CopyEdit( next, Settings.Edit );
Settings.Save();
NotifyChanged();
}
public void ResetEdit()
{
PushEditUndo();
CopyEdit( new EditSettings(), Settings.Edit );
Settings.Save();
NotifyChanged();
}
static void CopyEdit( EditSettings from, EditSettings to )
{
to.Brightness = from.Brightness; to.Contrast = from.Contrast; to.Saturation = from.Saturation;
to.Exposure = from.Exposure; to.Hue = from.Hue; to.Filter = from.Filter;
to.Sharpen = from.Sharpen; to.Blur = from.Blur; to.Vignette = from.Vignette; to.Grain = from.Grain;
to.Rotate = from.Rotate; to.FlipH = from.FlipH; to.FlipV = from.FlipV;
to.Border = from.Border; to.BorderColor = from.BorderColor;
to.Watermark = from.Watermark; to.WatermarkText = from.WatermarkText;
to.WatermarkAnchor = from.WatermarkAnchor; to.WatermarkSize = from.WatermarkSize; to.WatermarkColor = from.WatermarkColor;
}
public string SaveCurrent()
{
using var finished = BuildFinished();
if ( finished is null )
{
Log.Warning( "[Supershot] Nothing captured to save." );
return null;
}
// Shots opened from the library overwrite their original file in place; fresh captures write new files.
if ( _editingShot is not null && !string.IsNullOrEmpty( _editingShot.SavedPath ) )
{
var saved = SuperShotService.SaveOver( finished, _editingShot.SavedPath, Settings.Output.Quality );
if ( saved is not null )
{
_editingShot.Edit = Settings.Edit.Clone();
_editingShot.Size = finished.Size;
_editingShot.Thumbnail = MakeThumbnail( finished );
_editBaseline = Settings.Edit.Clone();
}
NotifyChanged();
return saved;
}
var path = SuperShotService.Save( finished, Settings.Output, Settings.Capture );
if ( path is not null && Gallery.Count > 0 )
Gallery[^1].SavedPath = path;
NotifyChanged();
return path;
}
public void UploadToArtPage()
{
var path = SaveCurrent();
SuperShotService.UploadToArtPage( path );
}
public async Task PostCurrentToAll()
{
var targets = SuperShotService.EnabledWebhooks( Settings.Share );
if ( targets.Count == 0 )
{
Log.Warning( "[Supershot] No enabled Discord channels configured (Discord tab)." );
return;
}
if ( !TryEncodeCurrent( out var bytes, out var name ) )
return;
SaveCurrent();
int sent = await DiscordWebhook.PostImageToMany( targets, bytes, name, Settings.Share.Message, SuperShotService.MimeType( Settings.Output.Format ) );
Log.Info( $"[Supershot] Posted to {sent}/{targets.Count} Discord channel(s)." );
}
public async Task<bool> PostCurrentToWebhook( DiscordWebhookConfig webhook )
{
if ( webhook is null || !webhook.IsConfigured )
{
Log.Warning( "[Supershot] That channel has no webhook URL set." );
return false;
}
if ( !TryEncodeCurrent( out var bytes, out var name ) )
return false;
SaveCurrent();
return await DiscordWebhook.PostImage( webhook.Url, bytes, name, webhook.ResolveMessage( Settings.Share.Message ), SuperShotService.MimeType( Settings.Output.Format ) );
}
bool TryEncodeCurrent( out byte[] bytes, out string filename )
{
bytes = null;
filename = null;
using var finished = BuildFinished();
if ( finished is null )
{
Log.Warning( "[Supershot] Nothing captured to post." );
return false;
}
bytes = SuperShotService.Encode( finished, Settings.Output.Format, Settings.Output.Quality );
filename = $"supershot.{SuperShotService.Extension( Settings.Output.Format )}";
return true;
}
void AddToGallery( Bitmap raw, EditSettings edit )
{
var shot = new CapturedShot
{
Raw = raw.Clone(),
Edit = edit.Clone(),
Size = raw.Size,
Thumbnail = MakeThumbnail( raw )
};
Gallery.Add( shot );
if ( Gallery.Count > 50 )
{
Gallery[0].Raw?.Dispose();
Gallery.RemoveAt( 0 );
}
}
static Pixmap MakeThumbnail( Bitmap source )
{
try
{
int w = 256;
int h = Math.Max( 1, (int)(w * (source.Height / (float)source.Width)) );
using var small = source.Resize( w, h );
var pm = new Pixmap( w, h );
pm.UpdateFromPixels( small );
return pm;
}
catch
{
return null;
}
}
public void RefreshGalleryFromDisk()
{
LoadSavedShotsIntoGallery();
NotifyChanged();
}
void LoadSavedShotsIntoGallery()
{
try
{
var folder = Settings.Output.ResolveFolder();
if ( string.IsNullOrEmpty( folder ) || !Directory.Exists( folder ) )
return;
var matches = new List<string>();
foreach ( var file in Directory.GetFiles( folder ) )
{
var ext = Path.GetExtension( file ).ToLowerInvariant();
if ( ext is ".png" or ".jpg" or ".jpeg" or ".webp" )
matches.Add( file );
}
matches.Sort( ( a, b ) => File.GetLastWriteTime( a ).CompareTo( File.GetLastWriteTime( b ) ) );
const int max = 30;
int start = Math.Max( 0, matches.Count - max );
for ( int i = start; i < matches.Count; i++ )
{
var file = matches[i];
if ( GalleryContainsPath( file ) )
continue;
try
{
using var bmp = Bitmap.CreateFromBytes( File.ReadAllBytes( file ) );
if ( bmp is null || !bmp.IsValid )
continue;
Gallery.Add( new CapturedShot
{
Raw = null,
Edit = new EditSettings(),
Size = bmp.Size,
Thumbnail = MakeThumbnail( bmp ),
SavedPath = file,
Time = File.GetLastWriteTime( file )
} );
}
catch
{
}
}
}
catch ( Exception e )
{
Log.Warning( $"[Supershot] Couldn't scan the gallery folder: {e.Message}" );
}
}
bool GalleryContainsPath( string path )
{
foreach ( var shot in Gallery )
{
if ( !string.IsNullOrEmpty( shot.SavedPath ) && string.Equals( shot.SavedPath, path, StringComparison.OrdinalIgnoreCase ) )
return true;
}
return false;
}
public void LoadFromGallery( CapturedShot shot )
{
if ( shot is null )
return;
Bitmap raw = null;
if ( shot.Raw is not null && shot.Raw.IsValid )
{
raw = shot.Raw.Clone();
}
else if ( !string.IsNullOrEmpty( shot.SavedPath ) && File.Exists( shot.SavedPath ) )
{
try { raw = Bitmap.CreateFromBytes( File.ReadAllBytes( shot.SavedPath ) ); }
catch ( Exception e ) { Log.Warning( $"[Supershot] Couldn't open '{shot.SavedPath}': {e.Message}" ); }
// Cache the decoded original so re-editing applies to these pixels, not a file we just baked edits into.
if ( raw is not null && raw.IsValid && shot.Raw is null )
shot.Raw = raw.Clone();
}
if ( raw is null || !raw.IsValid )
return;
RawCapture?.Dispose();
RawCapture = raw;
_editingShot = shot;
CopyEdit( shot.Edit, Settings.Edit );
_editBaseline = Settings.Edit.Clone();
Settings.Save();
NotifyChanged();
}
public void OpenInEditor( CapturedShot shot )
{
LoadFromGallery( shot );
if ( !HasCapture )
return;
_reviewingGalleryShot = true;
_editBaseline = Settings.Edit.Clone();
DockManager.RaiseDock( "Edit" );
DockManager.Update();
}
public void ReturnToLivePreview()
{
_reviewingGalleryShot = false;
_editingShot = null;
RawCapture?.Dispose();
RawCapture = null;
NotifyChanged();
}
public void LeaveEditReview()
{
if ( !_reviewingGalleryShot )
return;
_reviewingGalleryShot = false;
if ( HasCapture && HasUnsavedEditChanges() )
{
Dialog.AskConfirm(
() => { SaveCurrent(); ReturnToLivePreview(); },
() => ReturnToLivePreview(),
"Save your edits to this shot before returning to the live preview?",
"Unsaved changes", "Save", "Discard" );
}
else
{
ReturnToLivePreview();
}
}
public bool HasUnsavedEditChanges()
{
return _editBaseline is not null && !EditEquals( _editBaseline, Settings.Edit );
}
static bool EditEquals( EditSettings a, EditSettings b )
{
return a.Brightness == b.Brightness && a.Contrast == b.Contrast && a.Saturation == b.Saturation
&& a.Exposure == b.Exposure && a.Hue == b.Hue && a.Filter == b.Filter
&& a.Sharpen == b.Sharpen && a.Blur == b.Blur && a.Vignette == b.Vignette && a.Grain == b.Grain
&& a.Rotate == b.Rotate && a.FlipH == b.FlipH && a.FlipV == b.FlipV
&& a.Border == b.Border && a.BorderColor == b.BorderColor
&& a.Watermark == b.Watermark && a.WatermarkText == b.WatermarkText
&& a.WatermarkAnchor == b.WatermarkAnchor && a.WatermarkSize == b.WatermarkSize && a.WatermarkColor == b.WatermarkColor;
}
public void RemoveFromGallery( CapturedShot shot )
{
if ( shot is null ) return;
shot.Raw?.Dispose();
// Gallery is backed by the output folder, so deleting also removes the file (else it returns on reopen).
if ( !string.IsNullOrEmpty( shot.SavedPath ) )
{
try { if ( File.Exists( shot.SavedPath ) ) File.Delete( shot.SavedPath ); }
catch ( Exception e ) { Log.Warning( $"[Supershot] Couldn't delete '{shot.SavedPath}': {e.Message}" ); }
}
Gallery.Remove( shot );
NotifyChanged();
}
protected override void RestoreDefaultDockLayout()
{
var capture = new CapturePanel( this );
var preview = new PreviewPanel( this );
var edit = new EditPanel( this );
var gallery = new GalleryPanel( this );
var discord = new SharePanel( this );
var settings = new SettingsPanel( this );
DockManager.Clear();
DockManager.RegisterDockType( "Capture", "photo_camera", () => new CapturePanel( this ) );
DockManager.RegisterDockType( "Preview", "image", () => new PreviewPanel( this ) );
DockManager.RegisterDockType( "Edit", "tune", () => new EditPanel( this ) );
DockManager.RegisterDockType( "Gallery", "collections", () => new GalleryPanel( this ) );
DockManager.RegisterDockType( "Discord", "forum", () => new SharePanel( this ) );
DockManager.RegisterDockType( "Settings", "settings", () => new SettingsPanel( this ) );
DockManager.AddDock( null, preview, DockArea.Right, DockManager.DockProperty.HideOnClose, 0.62f );
DockManager.AddDock( null, capture, DockArea.Left, DockManager.DockProperty.HideOnClose, 0.38f );
DockManager.AddDock( capture, edit, DockArea.Inside, DockManager.DockProperty.HideOnClose );
DockManager.AddDock( capture, discord, DockArea.Inside, DockManager.DockProperty.HideOnClose );
DockManager.AddDock( capture, settings, DockArea.Inside, DockManager.DockProperty.HideOnClose );
DockManager.AddDock( preview, gallery, DockArea.Bottom, DockManager.DockProperty.HideOnClose, 0.25f );
DockManager.RaiseDock( "Capture" );
DockManager.Update();
RebuildMenuBar();
}
void RebuildMenuBar()
{
MenuBar.Clear();
var file = MenuBar.AddMenu( "File" );
file.AddOption( "Capture", "photo_camera", () => Capture() );
file.AddOption( "Capture All Package Thumbnails", "burst_mode", () => SuperShotService.CaptureAllPackageThumbnails() );
file.AddSeparator();
file.AddOption( "Save", "save", () => SaveCurrent() );
file.AddOption( "Copy Path", "content_copy", () => SuperShotService.CopyPathToClipboard( SaveCurrent() ) );
file.AddOption( "Open Output Folder", "folder", () => SuperShotService.RevealInExplorer( Settings.Output.ResolveFolder() ) );
file.AddSeparator();
file.AddOption( new Option( "Close" ) { Triggered = Close } );
var editMenu = MenuBar.AddMenu( "Edit" );
editMenu.AddOption( "Undo", "undo", Undo );
editMenu.AddOption( "Redo", "redo", Redo );
editMenu.AddSeparator();
editMenu.AddOption( "Reset Edits", "restart_alt", ResetEdit );
var discord = MenuBar.AddMenu( "Discord" );
discord.AboutToShow += () => OnDiscordMenu( discord );
var view = MenuBar.AddMenu( "View" );
view.AboutToShow += () => OnViewMenu( view );
var help = MenuBar.AddMenu( "Help" );
help.AddOption( "Upload to s&box Art Page", "image", UploadToArtPage );
help.AddOption( "About Supershot", "info", () => EditorUtility.DisplayDialog( "Supershot",
"Editor-only screenshot studio.\n\nCapture the scene-view freecam, edit, then save or share to Discord. Includes presets for YouTube, Steam and s&box package thumbnails (Square 512x512, Wide 910x512, Tall 512x910)." ) );
}
void OnDiscordMenu( Menu menu )
{
menu.Clear();
var webhooks = Settings.Share.Webhooks;
var configured = webhooks.FindAll( w => w is not null && w.IsConfigured );
if ( configured.Count == 0 )
{
var none = menu.AddOption( "No channels configured", "link_off" );
none.Enabled = false;
menu.AddSeparator();
menu.AddOption( "Open Discord Tab", "forum", () => DockManager.RaiseDock( "Discord" ) );
return;
}
menu.AddOption( "Post current shot to all Discord Channels", "share", () => _ = PostCurrentToAll() );
menu.AddOption( "Capture + Post to all Discord Channels", "burst_mode", () => _ = SuperShotService.CaptureAndPostAll() );
var favorites = configured.FindAll( w => w.Favorite );
if ( favorites.Count > 0 )
{
menu.AddSeparator();
var heading = menu.AddOption( "Favorites", "star" );
heading.Enabled = false;
foreach ( var wh in favorites )
{
var target = wh;
menu.AddOption( $"Post to {wh.Name}", "send", () => _ = PostCurrentToWebhook( target ) );
}
}
menu.AddSeparator();
foreach ( var wh in configured )
{
var sub = menu.AddMenu( wh.Name );
var target = wh;
sub.AddOption( "Post Current", "send", () => _ = PostCurrentToWebhook( target ) );
sub.AddOption( "Capture + Post", "photo_camera", () => _ = SuperShotService.CaptureAndPostTo( target ) );
sub.AddSeparator();
var favOpt = sub.AddOption( "Favorite" );
favOpt.Checkable = true;
favOpt.Checked = target.Favorite;
favOpt.Toggled += ( b ) => { target.Favorite = b; Settings.Save(); };
}
}
void OnViewMenu( Menu view )
{
view.Clear();
view.AddOption( "Restore To Default", "settings_backup_restore", RestoreDefaultDockLayout );
view.AddSeparator();
var live = view.AddOption( "Live Preview" );
live.Checkable = true;
live.Checked = Settings.LivePreview;
live.Toggled += ( b ) =>
{
Settings.LivePreview = b;
Settings.Save();
NotifyChanged();
};
view.AddSeparator();
foreach ( var dock in DockManager.DockTypes )
{
var o = view.AddOption( dock.Title, dock.Icon );
o.Checkable = true;
o.Checked = DockManager.IsDockOpen( dock.Title );
o.Toggled += ( b ) => DockManager.SetDockState( dock.Title, b );
}
}
public override void OnDestroyed()
{
base.OnDestroyed();
if ( _duplicate )
return;
if ( _instance == this )
_instance = null;
Settings.Save();
RawCapture?.Dispose();
foreach ( var s in Gallery )
s.Raw?.Dispose();
}
}