Editor/SuperShotWindow.cs

Editor window class for the Supershot studio. Manages capturing the scene view, building/editing SuperShotDocument instances, an in-memory gallery of captures, undo/redo for edits, saving/exporting images, and sharing to Discord via webhooks.

File AccessHttp CallsExternal Download
🌐 (via DiscordWebhook.PostImage / PostImageToMany) external webhook URLs supplied at runtime
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 SuperShotDocument Document { 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 SuperShotDocument ActiveDocument => _activeDocument;
	public SuperShotLayer SelectedLayer => _activeDocument?.SelectedLayer;

	public List<CapturedShot> Gallery { get; } = new();

	public event Action Changed;

	readonly List<SuperShotDocument> _undo = new();
	readonly List<SuperShotDocument> _redo = new();

	SuperShotDocument _activeDocument;
	SuperShotDocument _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()
	{
		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();
		BuildDefaultLayout();
		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 capture = BuildCaptureForShot();
		var raw = SuperShotService.CaptureRaw( 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;
		_activeDocument = CreateDocumentForCapture( raw, capture );
		_reviewingGalleryShot = false;
		_editingShot = null;
		_editBaseline = _activeDocument.Clone();

		AddToGallery( raw, _activeDocument );
		NotifyChanged();
	}

	public CaptureSettings BuildCaptureForShot()
	{
		var capture = CloneCapture( Settings.Capture );
		var preset = Settings.FindFilterPreset( capture.DefaultFilterPresetId );
		if ( preset?.UseForCapture == true && preset.PostFx is not null )
			capture.PostFx = preset.PostFx.Clone();
		return capture;
	}

	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.RenderDocument( RawCapture, _activeDocument, Settings );
	}

	public void PushEditUndo()
	{
		if ( _activeDocument is null )
			return;

		_undo.Add( _activeDocument.Clone() );
		if ( _undo.Count > 64 ) _undo.RemoveAt( 0 );
		_redo.Clear();
	}

	public void Undo()
	{
		if ( _undo.Count == 0 ) return;
		_redo.Add( _activeDocument?.Clone() );
		var prev = _undo[^1];
		_undo.RemoveAt( _undo.Count - 1 );
		SetActiveDocument( prev );
		Settings.Save();
		NotifyChanged();
	}

	public void Redo()
	{
		if ( _redo.Count == 0 ) return;
		_undo.Add( _activeDocument?.Clone() );
		var next = _redo[^1];
		_redo.RemoveAt( _redo.Count - 1 );
		SetActiveDocument( next );
		Settings.Save();
		NotifyChanged();
	}

	public void ResetEdit()
	{
		PushEditUndo();
		if ( _activeDocument is not null )
		{
			_activeDocument.SelectedFilterPresetId = "";
			_activeDocument.BaseEdit = new EditSettings();
			_activeDocument.Layers.Clear();
			_activeDocument.SelectedLayerId = "";
			CopyEdit( _activeDocument.BaseEdit, Settings.Edit );
		}
		else
		{
			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;
	}

	SuperShotDocument CreateDocumentForCapture( Bitmap raw, CaptureSettings capture )
	{
		Settings.EnsureBuiltinFilterPresets();
		var document = new SuperShotDocument
		{
			Name = "Captured Shot",
			Size = raw?.Size ?? default,
			Capture = CloneCapture( capture ?? Settings.Capture ),
			BaseEdit = new EditSettings(),
			SelectedFilterPresetId = Settings.Capture.DefaultFilterPresetId ?? ""
		};

		if ( Settings.FindFilterPreset( document.SelectedFilterPresetId ) is null )
			document.SelectedFilterPresetId = "";

		CopyEdit( document.BaseEdit, Settings.Edit );
		return document;
	}

	static CaptureSettings CloneCapture( CaptureSettings source )
	{
		if ( source is null )
			return new CaptureSettings();

		return new CaptureSettings
		{
			Source = source.Source,
			Resolution = source.Resolution,
			CustomWidth = source.CustomWidth,
			CustomHeight = source.CustomHeight,
			SuperSampling = source.SuperSampling,
			OverrideFov = source.OverrideFov,
			Fov = source.Fov,
			TransparentBackground = source.TransparentBackground,
			ShowGameUI = source.ShowGameUI,
			HideUI = source.HideUI,
			HideTags = source.HideTags,
			Delay = source.Delay,
			DefaultFilterPresetId = source.DefaultFilterPresetId,
			PostFx = source.PostFx?.Clone() ?? new PostFxSettings()
		};
	}

	void SetActiveDocument( SuperShotDocument document )
	{
		_activeDocument = document ?? new SuperShotDocument();
		_activeDocument.Layers ??= new List<SuperShotLayer>();
		_activeDocument.BaseEdit ??= new EditSettings();

		if ( _editingShot is not null )
			_editingShot.Document = _activeDocument;

		CopyEdit( _activeDocument.BaseEdit, Settings.Edit );
	}

	public void ApplyFilterPreset( FilterPreset preset )
	{
		if ( _activeDocument is null || preset is null )
			return;

		PushEditUndo();
		_activeDocument.SelectedFilterPresetId = preset.Id;
		Settings.Save();
		NotifyChanged();
	}

	public void CreateFilterPresetFromCurrent( string name )
	{
		if ( string.IsNullOrWhiteSpace( name ) )
			return;

		Settings.EnsureBuiltinFilterPresets();
		Settings.FilterPresets.Add( new FilterPreset
		{
			Name = name.Trim(),
			PhotoEditSettings = (_activeDocument?.BaseEdit ?? Settings.Edit).Clone(),
			PostFx = new PostFxSettings()
		} );
		Settings.Save();
		NotifyChanged();
	}

	public void DuplicateFilterPreset( FilterPreset preset )
	{
		if ( preset is null )
			return;

		var copy = preset.Clone();
		copy.Id = Guid.NewGuid().ToString( "N" );
		copy.Name = $"{preset.Name} Copy";
		copy.BuiltIn = false;
		Settings.FilterPresets.Add( copy );
		Settings.Save();
		NotifyChanged();
	}

	public void RenameFilterPreset( FilterPreset preset, string name )
	{
		if ( preset is null || preset.BuiltIn || string.IsNullOrWhiteSpace( name ) )
			return;

		preset.Name = name.Trim();
		Settings.Save();
		NotifyChanged();
	}

	public void DeleteFilterPreset( FilterPreset preset )
	{
		if ( preset is null || preset.BuiltIn )
			return;

		Settings.FilterPresets.Remove( preset );
		if ( _activeDocument?.SelectedFilterPresetId == preset.Id )
			_activeDocument.SelectedFilterPresetId = "";
		if ( Settings.Capture.DefaultFilterPresetId == preset.Id )
			Settings.Capture.DefaultFilterPresetId = "";
		Settings.Save();
		NotifyChanged();
	}

	public void SetDefaultFilterPreset( FilterPreset preset )
	{
		if ( preset is not null )
			preset.UseForCapture = true;
		Settings.Capture.DefaultFilterPresetId = preset?.Id ?? "";
		Settings.Save();
		NotifyChanged();
	}

	public void CopyFilterGameCameraCode( FilterPreset preset )
	{
		EditorUtility.Clipboard.Copy( SuperShotFilterExport.BuildCameraSnippet( preset ) );
		Log.Info( $"[Supershot] Copied game camera code for filter '{preset?.Name ?? "None"}'." );
	}

	public void ResetBuiltinFilterPresets()
	{
		Settings.FilterPresets.RemoveAll( p => p?.BuiltIn == true );
		Settings.EnsureBuiltinFilterPresets();
		Settings.Save();
		NotifyChanged();
	}

	public void AddTextLayer()
	{
		AddLayer( new SuperShotLayer { Name = "Text", Kind = SuperShotLayerKind.Text } );
	}

	public void AddAdjustmentLayer()
	{
		AddLayer( new SuperShotLayer { Name = "Adjustment", Kind = SuperShotLayerKind.Adjustment } );
	}

	public void AddImageLayer( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) )
			return;

		path = path.Trim( '"', ' ' );
		var size = new Vector2( 0.4f, 0.4f );

		try
		{
			if ( File.Exists( path ) )
			{
				using var probe = Bitmap.CreateFromBytes( File.ReadAllBytes( path ) );
				if ( probe is not null && probe.IsValid && probe.Width > 0 && probe.Height > 0 )
				{
					var docSize = _activeDocument?.Size ?? default;
					float canvasAspect = docSize.x > 0 && docSize.y > 0
						? docSize.x / (float)docSize.y
						: 1f;
					float imageAspect = probe.Width / (float)probe.Height;

					float h = 0.4f;
					float w = MathX.Clamp( h * imageAspect / canvasAspect, 0.05f, 1f );
					size = new Vector2( w, h );
				}
			}
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Supershot] Couldn't read image layer dimensions '{path}': {e.Message}" );
		}

		AddLayer( new SuperShotLayer
		{
			Name = Path.GetFileNameWithoutExtension( path ),
			Kind = SuperShotLayerKind.Image,
			Size = size,
			Image = new ImageLayerSettings { Path = path }
		} );
	}

	void AddLayer( SuperShotLayer layer )
	{
		if ( _activeDocument is null || layer is null )
			return;

		PushEditUndo();
		_activeDocument.Layers ??= new List<SuperShotLayer>();
		_activeDocument.Layers.Add( layer );
		_activeDocument.SelectedLayerId = layer.Id;
		NotifyChanged();
	}

	public void SelectLayer( SuperShotLayer layer )
	{
		if ( _activeDocument is null || layer is null )
			return;

		_activeDocument.SelectedLayerId = layer.Id;
		NotifyChanged();
	}

	public void SetSelectedLayerSilent( SuperShotLayer layer )
	{
		if ( _activeDocument is null )
			return;

		_activeDocument.SelectedLayerId = layer?.Id ?? "";
	}

	public void ToggleLayerVisibility( SuperShotLayer layer )
	{
		if ( layer is null )
			return;

		PushEditUndo();
		layer.Visible = !layer.Visible;
		NotifyChanged();
	}

	public void AlignSelectedLayer( int? horizontal, int? vertical )
	{
		var layer = SelectedLayer;
		if ( layer is null || !layer.HasVisualBox )
			return;

		PushEditUndo();
		var pos = layer.Position;
		var s = layer.Size;

		if ( horizontal == -1 ) pos.x = s.x * 0.5f;
		else if ( horizontal == 0 ) pos.x = 0.5f;
		else if ( horizontal == 1 ) pos.x = 1f - s.x * 0.5f;

		if ( vertical == -1 ) pos.y = s.y * 0.5f;
		else if ( vertical == 0 ) pos.y = 0.5f;
		else if ( vertical == 1 ) pos.y = 1f - s.y * 0.5f;

		layer.Position = pos;
		NotifyChanged();
	}

	public void FillCanvasWithSelectedLayer()
	{
		var layer = SelectedLayer;
		if ( layer is null || !layer.HasVisualBox )
			return;

		PushEditUndo();
		layer.Position = new Vector2( 0.5f, 0.5f );
		layer.Size = new Vector2( 1f, 1f );
		NotifyChanged();
	}

	public void DuplicateSelectedLayer()
	{
		var layer = SelectedLayer;
		if ( layer is null )
			return;

		PushEditUndo();
		var copy = layer.Clone( true );
		_activeDocument.Layers.Add( copy );
		_activeDocument.SelectedLayerId = copy.Id;
		NotifyChanged();
	}

	public void DeleteSelectedLayer()
	{
		var layer = SelectedLayer;
		if ( layer is null )
			return;

		PushEditUndo();
		_activeDocument.Layers.Remove( layer );
		_activeDocument.SelectedLayerId = _activeDocument.Layers.Count > 0 ? _activeDocument.Layers[^1].Id : "";
		NotifyChanged();
	}

	public void MoveSelectedLayer( int direction )
	{
		var layer = SelectedLayer;
		if ( layer is null )
			return;

		var index = _activeDocument.Layers.IndexOf( layer );
		var next = Math.Clamp( index + direction, 0, _activeDocument.Layers.Count - 1 );
		if ( next == index )
			return;

		PushEditUndo();
		_activeDocument.Layers.RemoveAt( index );
		_activeDocument.Layers.Insert( next, layer );
		NotifyChanged();
	}

	public string SaveCurrentAsNew()
	{
		using var finished = BuildFinished();
		if ( finished is null )
		{
			Log.Warning( "[Supershot] Nothing captured to save." );
			return null;
		}

		var path = SuperShotService.Save( finished, Settings.Output, Settings.Capture );
		if ( path is null )
			return null;

		if ( _activeDocument is not null )
		{
			var previousEditingShot = _editingShot;
			_editingShot = null;
			SetActiveDocument( _activeDocument.Clone() );
			_editingShot = previousEditingShot;
		}

		if ( _activeDocument is not null )
		{
			_activeDocument.SourcePath = SuperShotService.SaveSourceImage( RawCapture, path ) ?? _activeDocument.SourcePath;
			_activeDocument.ExportPath = path;
			SuperShotService.SaveDocumentSidecar( _activeDocument, path );
		}

		var shot = new CapturedShot
		{
			Raw = RawCapture?.Clone(),
			Edit = _activeDocument?.BaseEdit?.Clone() ?? Settings.Edit.Clone(),
			Document = _activeDocument,
			Size = finished.Size,
			Thumbnail = MakeThumbnail( finished ),
			SavedPath = path,
			Time = DateTime.Now
		};
		Gallery.Add( shot );
		_editingShot = shot;
		_editBaseline = _activeDocument?.Clone();
		NotifyChanged();
		return path;
	}

	public string SaveCurrent()
	{
		using var finished = BuildFinished();
		if ( finished is null )
		{
			Log.Warning( "[Supershot] Nothing captured to save." );
			return null;
		}

		if ( _editingShot is not null && !string.IsNullOrEmpty( _editingShot.SavedPath ) )
		{
			var saved = SuperShotService.SaveOver( finished, _editingShot.SavedPath, Settings.Output.Quality );
			if ( saved is not null )
			{
				if ( _activeDocument is not null )
				{
					_activeDocument.SourcePath = SuperShotService.SaveSourceImage( RawCapture, saved ) ?? _activeDocument.SourcePath;
					_activeDocument.ExportPath = saved;
					SuperShotService.SaveDocumentSidecar( _activeDocument, saved );
				}

				_editingShot.Edit = _activeDocument?.BaseEdit?.Clone() ?? Settings.Edit.Clone();
				_editingShot.Document = _activeDocument;
				_editingShot.Size = finished.Size;
				_editingShot.Thumbnail = MakeThumbnail( finished );
				_editBaseline = _activeDocument?.Clone();
			}

			NotifyChanged();
			return saved;
		}

		var path = SuperShotService.Save( finished, Settings.Output, Settings.Capture );
		if ( path is not null && Gallery.Count > 0 )
		{
			if ( _activeDocument is not null )
			{
				_activeDocument.SourcePath = SuperShotService.SaveSourceImage( RawCapture, path ) ?? _activeDocument.SourcePath;
				_activeDocument.ExportPath = path;
				SuperShotService.SaveDocumentSidecar( _activeDocument, path );
			}

			Gallery[^1].SavedPath = path;
			Gallery[^1].Document = _activeDocument;
			Gallery[^1].Edit = _activeDocument?.BaseEdit?.Clone() ?? Settings.Edit.Clone();
			_editBaseline = _activeDocument?.Clone();
		}

		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, 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, SuperShotDocument document )
	{
		var shot = new CapturedShot
		{
			Raw = raw.Clone(),
			Edit = document?.BaseEdit?.Clone() ?? Settings.Edit.Clone(),
			Document = document,
			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" && !SuperShotService.IsSupershotCompanionFile( file ) )
					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;

					var document = SuperShotService.LoadDocumentSidecar( file );
					document ??= new SuperShotDocument
					{
						Name = Path.GetFileNameWithoutExtension( file ),
						ExportPath = file,
						SourcePath = file,
						Size = bmp.Size
					};

					Gallery.Add( new CapturedShot
					{
						Raw = null,
						Edit = document.BaseEdit?.Clone() ?? new EditSettings(),
						Document = document,
						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;
		var document = shot.Document;

		if ( document is null && !string.IsNullOrEmpty( shot.SavedPath ) )
			document = SuperShotService.LoadDocumentSidecar( shot.SavedPath );

		var sourcePath = document?.SourcePath;
		if ( !string.IsNullOrEmpty( sourcePath ) && File.Exists( sourcePath ) )
		{
			try { raw = Bitmap.CreateFromBytes( File.ReadAllBytes( sourcePath ) ); }
			catch ( Exception e ) { Log.Warning( $"[Supershot] Couldn't open editable source '{sourcePath}': {e.Message}" ); }
		}

		if ( shot.Raw is not null && shot.Raw.IsValid )
		{
			raw ??= shot.Raw.Clone();
		}
		else if ( !string.IsNullOrEmpty( shot.SavedPath ) && File.Exists( shot.SavedPath ) )
		{
			if ( raw is null )
			{
				try { raw = Bitmap.CreateFromBytes( File.ReadAllBytes( shot.SavedPath ) ); }
				catch ( Exception e ) { Log.Warning( $"[Supershot] Couldn't open '{shot.SavedPath}': {e.Message}" ); }
			}

			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;
		if ( document is null )
		{
			document = new SuperShotDocument
			{
				Name = Path.GetFileNameWithoutExtension( shot.SavedPath ) ?? "Flat Image",
				ExportPath = shot.SavedPath,
				SourcePath = shot.SavedPath,
				Size = raw.Size,
				BaseEdit = shot.Edit?.Clone() ?? new EditSettings()
			};
		}

		document.Size = raw.Size;
		shot.Document = document;
		SetActiveDocument( document );
		_editBaseline = _activeDocument.Clone();
		Settings.Save();
		NotifyChanged();
	}

	public void OpenInEditor( CapturedShot shot )
	{
		LoadFromGallery( shot );
		if ( !HasCapture )
			return;

		_reviewingGalleryShot = true;
		_editBaseline = _activeDocument?.Clone();

		DockManager.RaiseDock( "Edit" );
		DockManager.Update();
	}

	public void ReturnToLivePreview()
	{
		_reviewingGalleryShot = false;
		_editingShot = null;
		_activeDocument = 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 && !DocumentEquals( _editBaseline, _activeDocument );
	}

	static bool DocumentEquals( SuperShotDocument a, SuperShotDocument b )
	{
		if ( a is null || b is null )
			return a == b;

		return Json.Serialize( a ) == Json.Serialize( b );
	}

	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();

		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();
	}

	public void RestoreStudioLayout()
	{
		BuildDefaultLayout();
	}

	protected override void BuildDefaultLayout()
	{
		DockManager.RegisterDock( new() { Title = "Home", Icon = "home", Area = DockArea.Center, CreateAction = () => new CapturePanel( this ) } );
		DockManager.RegisterDock( new() { Title = "Preview", Icon = "image", Area = DockArea.Center, CreateAction = () => new PreviewPanel( this ) } );
		DockManager.RegisterDock( new() { Title = "Edit", Icon = "tune", Area = DockArea.Center, CreateAction = () => new EditPanel( this ) } );
		DockManager.RegisterDock( new() { Title = "Gallery", Icon = "collections", Area = DockArea.Center, CreateAction = () => new GalleryPanel( this ) } );
		DockManager.RegisterDock( new() { Title = "Discord", Icon = "forum", Area = DockArea.Center, CreateAction = () => new SharePanel( this ) } );
		DockManager.RegisterDock( new() { Title = "Settings", Icon = "settings", Area = DockArea.Center, CreateAction = () => new SettingsPanel( this ) } );

		var home = DockManager.OpenDock( "Home", DockArea.Center );
		DockManager.OpenDock( "Edit", DockArea.Center, home );
		DockManager.OpenDock( "Gallery", DockArea.Center, home );
		DockManager.OpenDock( "Discord", DockArea.Center, home );
		DockManager.OpenDock( "Settings", DockArea.Center, home );

		DockManager.RaiseDock( "Home" );

		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 appMenu = MenuBar.AddMenu( "Home" );
		appMenu.AddOption( "Open Home", "home", () => DockManager.RaiseDock( "Home" ) );
		appMenu.AddOption( "Open Editor", "tune", () => DockManager.RaiseDock( "Edit" ) );
		appMenu.AddOption( "Open Gallery", "collections", () => DockManager.RaiseDock( "Gallery" ) );
		appMenu.AddOption( "Open Discord", "forum", () => DockManager.RaiseDock( "Discord" ) );
		appMenu.AddSeparator();
		appMenu.AddOption( "Undo", "undo", Undo );
		appMenu.AddOption( "Redo", "redo", Redo );
		appMenu.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 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", BuildDefaultLayout );
		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();
	}
}