Editor/SuperShotEdit.cs

Editor utility for applying photo edits and layered composition to a Bitmap for the SuperShot editor. It applies transforms, color adjustments, filters, vignette, grain, borders, watermarks, and draws text/image/adjustment layers from a SuperShotDocument.

File Access
using System;
using System.IO;
using Sandbox;

namespace Editor.SuperShot;

public static class SuperShotEdit
{
	public static Bitmap RenderDocument( Bitmap source, SuperShotDocument document, SuperShotSettings settings )
	{
		if ( source is null )
			return null;

		if ( document is null )
			return Apply( source, settings?.Edit ?? new EditSettings() );

		var working = source.Clone();

		try
		{
			if ( settings?.FindFilterPreset( document.SelectedFilterPresetId ) is { } preset )
				ApplyEditPass( working, preset.PhotoEditSettings );

			ApplyEditPass( working, document.BaseEdit );

			if ( document.Layers is not null )
			{
				foreach ( var layer in document.Layers )
					ApplyLayer( working, layer );
			}
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Supershot] Document render failed: {e.Message}" );
		}

		return working;
	}

	public static Bitmap Apply( Bitmap source, EditSettings edit )
	{
		if ( source is null )
			return null;

		var working = source.Clone();

		try
		{
			working = ApplyFullEdit( working, edit );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Supershot] Edit pass failed: {e.Message}" );
		}

		return working;
	}

	static Bitmap ApplyFullEdit( Bitmap bmp, EditSettings edit )
	{
		bmp = ApplyTransform( bmp, edit );
		ApplyEditPass( bmp, edit );
		bmp = ApplyBorder( bmp, edit );
		ApplyWatermark( bmp, edit );
		return bmp;
	}

	static void ApplyEditPass( Bitmap bmp, EditSettings edit )
	{
		if ( bmp is null || edit is null )
			return;

		ApplyColor( bmp, edit );
		ApplyFilter( bmp, edit.Filter );
		ApplyDetail( bmp, edit );
		ApplyVignette( bmp, edit.Vignette );
		ApplyGrain( bmp, edit.Grain );
	}

	static void ApplyLayer( Bitmap canvas, SuperShotLayer layer )
	{
		if ( canvas is null || layer is null || !layer.Visible || layer.Opacity <= 0f )
			return;

		switch ( layer.Kind )
		{
			case SuperShotLayerKind.Text:
				DrawTextLayer( canvas, layer );
				break;
			case SuperShotLayerKind.Image:
				DrawImageLayer( canvas, layer );
				break;
			case SuperShotLayerKind.Adjustment:
				ApplyEditPass( canvas, layer.Adjustment?.Edit );
				break;
		}
	}

	static void DrawTextLayer( Bitmap canvas, SuperShotLayer layer )
	{
		var text = layer.Text;
		if ( text is null || string.IsNullOrWhiteSpace( text.Text ) )
			return;

		var rect = layer.PixelRect( canvas.Width, canvas.Height );
		int lines = CountLines( text.Text );
		float fontPx = layer.FontPixels( canvas.Height, lines );
		var flags = AnchorToTextFlag( text.Anchor ) | TextFlag.WordWrap;

		if ( text.Shadow )
		{
			var shadow = new TextRendering.Scope( text.Text, text.ShadowColor.WithAlpha( text.ShadowColor.a * layer.Opacity ), fontPx );
			bmpDrawText( canvas, shadow, OffsetRect( rect, text.ShadowOffset ), flags );
		}

		var color = text.Color.WithAlpha( text.Color.a * layer.Opacity );
		var scope = new TextRendering.Scope( text.Text, color, fontPx );
		bmpDrawText( canvas, scope, rect, flags );
	}

	static void DrawImageLayer( Bitmap canvas, SuperShotLayer layer )
	{
		var path = layer.Image?.Path;
		if ( string.IsNullOrWhiteSpace( path ) || !File.Exists( path ) )
			return;

		try
		{
			using var image = Bitmap.CreateFromBytes( File.ReadAllBytes( path ) );
			if ( image is null || !image.IsValid )
				return;

			canvas.DrawBitmap( image, layer.PixelRect( canvas.Width, canvas.Height ) );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Supershot] Couldn't draw image layer '{path}': {e.Message}" );
		}
	}

	static int CountLines( string text )
	{
		if ( string.IsNullOrEmpty( text ) )
			return 1;

		int lines = 1;
		foreach ( var c in text )
		{
			if ( c == '\n' )
				lines++;
		}
		return lines;
	}

	static Rect OffsetRect( Rect rect, Vector2 offset )
	{
		return new Rect( rect.Left + offset.x, rect.Top + offset.y, rect.Width, rect.Height );
	}

	static TextFlag AnchorToTextFlag( ShotAnchor anchor )
	{
		return anchor switch
		{
			ShotAnchor.TopLeft => TextFlag.LeftTop,
			ShotAnchor.TopRight => TextFlag.RightTop,
			ShotAnchor.BottomLeft => TextFlag.LeftBottom,
			ShotAnchor.BottomRight => TextFlag.RightBottom,
			_ => TextFlag.Center
		};
	}

	static void bmpDrawText( Bitmap bmp, TextRendering.Scope scope, Rect rect, TextFlag flags )
	{
		bmp.DrawText( scope, rect, flags );
	}

	static Bitmap ApplyTransform( Bitmap bmp, EditSettings edit )
	{
		float degrees = edit.Rotate switch
		{
			ShotRotate.Cw90 => 90f,
			ShotRotate.Half => 180f,
			ShotRotate.Ccw90 => 270f,
			_ => 0f
		};

		if ( degrees != 0f )
		{
			var rotated = bmp.Rotate( degrees );
			bmp.Dispose();
			bmp = rotated;
		}

		if ( edit.FlipH )
		{
			var f = bmp.FlipHorizontal();
			bmp.Dispose();
			bmp = f;
		}

		if ( edit.FlipV )
		{
			var f = bmp.FlipVertical();
			bmp.Dispose();
			bmp = f;
		}

		return bmp;
	}

	static void ApplyColor( Bitmap bmp, EditSettings edit )
	{
		float brightness = edit.Brightness * MathF.Pow( 2f, edit.Exposure );
		bool changed = brightness != 1f || edit.Contrast != 1f || edit.Saturation != 1f || edit.Hue != 0f;
		if ( changed )
			bmp.Adjust( brightness, edit.Contrast, edit.Saturation, edit.Hue );
	}

	static void ApplyFilter( Bitmap bmp, ShotFilter filter )
	{
		switch ( filter )
		{
			case ShotFilter.Warm:
				bmp.Tint( new Color( 1f, 0.92f, 0.82f ) );
				break;
			case ShotFilter.Cool:
				bmp.Tint( new Color( 0.84f, 0.92f, 1f ) );
				break;
			case ShotFilter.Vintage:
				bmp.Adjust( 1.02f, 0.92f, 0.7f, 0f );
				bmp.Tint( new Color( 1f, 0.9f, 0.75f ) );
				break;
			case ShotFilter.Cinematic:
				bmp.Adjust( 1f, 1.15f, 0.9f, 0f );
				bmp.Tint( new Color( 0.92f, 0.97f, 1f ) );
				break;
			case ShotFilter.BlackAndWhite:
				bmp.Adjust( 1f, 1.05f, 0f, 0f );
				break;
			case ShotFilter.HighContrast:
				bmp.Adjust( 1f, 1.35f, 1.05f, 0f );
				break;
		}
	}

	static void ApplyDetail( Bitmap bmp, EditSettings edit )
	{
		if ( edit.Sharpen > 0f )
			bmp.Sharpen( edit.Sharpen );

		if ( edit.Blur > 0f )
			bmp.Blur( edit.Blur );
	}

	static void ApplyVignette( Bitmap bmp, float intensity )
	{
		if ( intensity <= 0f )
			return;

		var center = bmp.Center;
		float radius = MathF.Sqrt( center.x * center.x + center.y * center.y );

		var g = new Gradient();
		g.AddColor( 0f, Color.Black );
		g.AddColor( 1f, Color.Black );
		g.AddAlpha( 0f, 0f );
		g.AddAlpha( 0.55f, 0f );
		g.AddAlpha( 1f, MathX.Clamp( intensity, 0f, 1f ) );

		bmp.SetRadialGradient( center, radius, g );
		bmp.DrawRect( bmp.Rect );
	}

	static void ApplyGrain( Bitmap bmp, float intensity )
	{
		if ( intensity <= 0f )
			return;

		int nw = Math.Clamp( bmp.Width / 2, 8, 1024 );
		int nh = Math.Clamp( bmp.Height / 2, 8, 1024 );

		using var noise = new Bitmap( nw, nh );
		var pixels = new Color[nw * nh];
		float a = MathX.Clamp( intensity, 0f, 1f ) * 0.5f;
		for ( int i = 0; i < pixels.Length; i++ )
		{
			float v = System.Random.Shared.Float( 0f, 1f );
			pixels[i] = new Color( v, v, v, a );
		}
		noise.SetPixels( pixels );

		bmp.SetAntialias( true );
		bmp.DrawBitmap( noise, bmp.Rect );
	}

	static Bitmap ApplyBorder( Bitmap bmp, EditSettings edit )
	{
		if ( edit.Border <= 0 )
			return bmp;

		int b = Math.Min( edit.Border, Math.Min( bmp.Width, bmp.Height ) / 2 - 1 );
		if ( b <= 0 )
			return bmp;

		var bordered = new Bitmap( bmp.Width, bmp.Height );
		bordered.Clear( edit.BorderColor );
		bordered.DrawBitmap( bmp, new Rect( b, b, bmp.Width - b * 2, bmp.Height - b * 2 ) );
		bmp.Dispose();
		return bordered;
	}

	static void ApplyWatermark( Bitmap bmp, EditSettings edit )
	{
		if ( !edit.Watermark || string.IsNullOrWhiteSpace( edit.WatermarkText ) )
			return;

		float margin = MathF.Max( 8f, edit.WatermarkSize * 0.4f );
		var rect = new Rect( margin, margin, bmp.Width - margin * 2f, bmp.Height - margin * 2f );

		var flags = edit.WatermarkAnchor switch
		{
			ShotAnchor.TopLeft => TextFlag.LeftTop,
			ShotAnchor.TopRight => TextFlag.RightTop,
			ShotAnchor.BottomLeft => TextFlag.LeftBottom,
			ShotAnchor.BottomRight => TextFlag.RightBottom,
			_ => TextFlag.Center
		};

		var scope = new TextRendering.Scope( edit.WatermarkText, edit.WatermarkColor, edit.WatermarkSize );
		bmp.DrawText( scope, rect, flags );
	}
}