Editor/CitizenRetarget/CitizenRetargetPreviewWidgets.cs
#nullable enable
using Editor.Widgets;
using NVector3 = System.Numerics.Vector3;

namespace Editor.CitizenRetarget;

internal sealed class RetargetLivePreviewWidget : Widget
{
	private readonly SceneCanvas _sceneCanvas;
	private readonly Label _statusLabel;
	private readonly FloatSlider? _timeline;
	private readonly FloatSlider? _zoomSlider;
	private readonly ComboBox? _speedCombo;
	private readonly Checkbox? _followTarget;
	private readonly bool _compactMode;
	private float _playbackRate = 1.0f;
	private bool _isPaused;
	private bool _isSeeking;
	private bool _syncingCameraControls;

	public RetargetLivePreviewWidget( Widget parent, bool compactMode = false ) : base( parent )
	{
		_compactMode = compactMode;
		var rootLayout = Layout.Column();
		Layout = rootLayout;
		rootLayout.Margin = 0;
		rootLayout.Spacing = compactMode ? 4 : 6;

		_sceneCanvas = new SceneCanvas( this );

		if ( !compactMode )
		{
			var toolbar = new ToolBar( this );
			toolbar.SetIconSize( 16 );
			toolbar.AddOption( new Option( "Play/Pause", "play_arrow", () => TogglePlayPause() ) );
			toolbar.AddOption( new Option( "Restart", "replay", Restart ) );
			toolbar.AddOption( new Option( "Frame Character", "center_focus_strong", FrameCharacter ) );
			toolbar.AddOption( new Option( "Reset View", "home", ResetCamera ) );
			toolbar.AddSeparator();
			toolbar.AddOption( new Option( "Orbit Left", "rotate_left", OrbitLeft ) );
			toolbar.AddOption( new Option( "Orbit Right", "rotate_right", OrbitRight ) );
			toolbar.AddOption( new Option( "Tilt Up", "keyboard_arrow_up", TiltUp ) );
			toolbar.AddOption( new Option( "Tilt Down", "keyboard_arrow_down", TiltDown ) );
			toolbar.AddOption( new Option( "Zoom In", "zoom_in", ZoomIn ) );
			toolbar.AddOption( new Option( "Zoom Out", "zoom_out", ZoomOut ) );
			toolbar.AddSeparator();

			var speedHost = new Widget( toolbar );
			var speedLayout = Layout.Row();
			speedHost.Layout = speedLayout;
			speedLayout.Spacing = 4;
			speedLayout.Add( new Label( "Speed" ) );
			_speedCombo = speedLayout.Add( new ComboBox() );
			_speedCombo.AddItem( "0.25x" );
			_speedCombo.AddItem( "0.5x" );
			_speedCombo.AddItem( "1.0x" );
			_speedCombo.CurrentIndex = 2;
			_speedCombo.ItemChanged += ApplyPlaybackRate;
			toolbar.AddWidget( speedHost );

			rootLayout.Add( toolbar );

			var cameraControls = new Widget( this );
			var cameraLayout = Layout.Row();
			cameraControls.Layout = cameraLayout;
			cameraLayout.Spacing = 8;
			cameraLayout.Add( new Label( "Camera" ) );
			_followTarget = cameraLayout.Add( new Checkbox( "Follow" ) );
			TrySetFollowTargetState( CheckState.On );
			_followTarget.StateChanged += _ =>
			{
				if ( _syncingCameraControls )
					return;

				ApplyFollowTargetToggle();
			};
			cameraLayout.Add( new Label( "Zoom" ) );
			_zoomSlider = cameraLayout.Add( new FloatSlider( cameraControls ), 1 );
			_zoomSlider.Minimum = SceneCanvas.MinimumZoomFactor;
			_zoomSlider.Maximum = SceneCanvas.MaximumZoomFactor;
			TrySetZoomSliderValue( SceneCanvas.DefaultZoomFactor );
			_zoomSlider.OnValueEdited = () =>
			{
				if ( _syncingCameraControls )
					return;

				ApplyZoomSlider();
			};
			rootLayout.Add( cameraControls );
		}
		else
		{
			_speedCombo = null;
			_followTarget = null;
			_zoomSlider = null;
		}

		_sceneCanvas.CameraStateChanged += SyncCameraControls;
		rootLayout.Add( _sceneCanvas, 1 );

		var footer = new Widget( this );
		var footerLayout = Layout.Column();
		footer.Layout = footerLayout;
		footerLayout.Spacing = compactMode ? 2 : 4;
		rootLayout.Add( footer );

		_statusLabel = footerLayout.Add( new Label.Body( "Live preview is waiting for a compiled Citizen animation." ) { Color = Theme.Text.WithAlpha( 0.78f ) } );
		if ( compactMode )
		{
			_timeline = null;
		}
		else
		{
			_timeline = footerLayout.Add( new FloatSlider( footer ) );
			_timeline.Minimum = 0;
			_timeline.Maximum = 1;
			_timeline.OnValueEdited = () =>
			{
				_isSeeking = true;
				_sceneCanvas.SeekNormalized( _timeline.Value );
				_isSeeking = false;
			};
		}
	}

	public void LoadAnimation( string vmdlResourcePath, string sequenceName )
	{
		_sceneCanvas.LoadAnimation( vmdlResourcePath, sequenceName );
		_statusLabel.Text = string.IsNullOrWhiteSpace( sequenceName )
			? $"Loaded {vmdlResourcePath}"
			: $"Loaded {sequenceName} from {vmdlResourcePath}";
		_isPaused = false;
		ApplyPlaybackRate();
		TrySetTimelineValue( 0f );
		SyncCameraControls();
	}

	public void ClearAnimation( string message )
	{
		_sceneCanvas.ClearAnimation();
		_statusLabel.Text = string.IsNullOrWhiteSpace( message )
			? "Live preview is waiting for a compiled Citizen animation."
			: message;
		_isPaused = false;
		TrySetTimelineValue( 0f );
		SyncCameraControls();
	}

	public void Restart()
	{
		_sceneCanvas.SeekNormalized( 0 );
		TrySetTimelineValue( 0f );
	}

	public void ResetCamera()
	{
		_sceneCanvas.ResetCamera();
	}

	public void FrameCharacter()
	{
		_sceneCanvas.FrameCharacter();
	}

	public void TogglePlayPause()
	{
		_isPaused = !_isPaused;
		if ( _isPaused )
		{
			_sceneCanvas.SetPaused( true );
			return;
		}

		ApplyPlaybackRate();
	}

	private void ApplyPlaybackRate()
	{
		var speedText = _speedCombo?.CurrentText ?? "1.0x";
		_playbackRate = speedText switch
		{
			"0.25x" => 0.25f,
			"0.5x" => 0.5f,
			_ => 1.0f
		};

		if ( _isPaused )
			return;

		_sceneCanvas.SetPlaybackRate( _playbackRate );
	}

	private void OrbitLeft() => _sceneCanvas.OrbitBy( -18f, 0f );
	private void OrbitRight() => _sceneCanvas.OrbitBy( 18f, 0f );
	private void TiltUp() => _sceneCanvas.OrbitBy( 0f, 8f );
	private void TiltDown() => _sceneCanvas.OrbitBy( 0f, -8f );
	private void ZoomIn() => _sceneCanvas.AdjustZoom( 0.88f );
	private void ZoomOut() => _sceneCanvas.AdjustZoom( 1.12f );
	private void ApplyFollowTargetToggle()
	{
		if ( _followTarget is null )
			return;

		_sceneCanvas.SetFollowTarget( TryGetFollowTargetState() == CheckState.On );
	}
	private void ApplyZoomSlider() => _sceneCanvas.SetZoomFactor( TryGetZoomSliderValue() );

	[EditorEvent.Frame]
	private void Frame()
	{
		if ( _isSeeking )
			return;

		TrySetTimelineValue( _sceneCanvas.TimeNormalized );
	}

	private void SyncCameraControls()
	{
		if ( _compactMode )
			return;

		_syncingCameraControls = true;
		TrySetZoomSliderValue( _sceneCanvas.ZoomFactor );
		TrySetFollowTargetState( _sceneCanvas.FollowTarget ? CheckState.On : CheckState.Off );
		_syncingCameraControls = false;
	}

	private CheckState TryGetFollowTargetState()
	{
		if ( _followTarget is null )
			return CheckState.Off;

		try
		{
			return _followTarget.State;
		}
		catch
		{
			return CheckState.Off;
		}
	}

	private float TryGetZoomSliderValue()
	{
		if ( _zoomSlider is null )
			return SceneCanvas.DefaultZoomFactor;

		try
		{
			return _zoomSlider.Value;
		}
		catch
		{
			return SceneCanvas.DefaultZoomFactor;
		}
	}

	private void TrySetFollowTargetState( CheckState state )
	{
		if ( _followTarget is null )
			return;

		try
		{
			_followTarget.State = state;
		}
		catch
		{
		}
	}

	private void TrySetZoomSliderValue( float value )
	{
		if ( _zoomSlider is null )
			return;

		try
		{
			_zoomSlider.Value = value;
		}
		catch
		{
		}
	}

	private void TrySetTimelineValue( float value )
	{
		if ( _timeline is null )
			return;

		try
		{
			_timeline.Value = value;
		}
		catch
		{
		}
	}

	private sealed class SceneCanvas : SceneRenderingWidget
	{
		public const float DefaultZoomFactor = 1.0f;
		public const float MinimumZoomFactor = 0.65f;
		public const float MaximumZoomFactor = 2.75f;
		private const float DefaultYaw = 155f;
		private const float DefaultPitch = 10f;

		private SceneWorld? _world;
		private SceneModel? _sceneModel;
		private string _currentSequence = string.Empty;
		private float _appliedPlaybackRate;
		private float _orbitYaw = DefaultYaw;
		private float _orbitPitch = DefaultPitch;
		private float _zoomFactor = DefaultZoomFactor;
		private bool _followTarget = true;
		private Vector3 _focusPoint;
		private Vector3 _panOffset;
		private float _contentRadius = 48f;
		private bool _hasValidFraming;
		private bool _orbitDragging;
		private bool _panDragging;
		private Vector2 _lastMousePosition;

		public Action? CameraStateChanged { get; set; }

		public float TimeNormalized
		{
			get
			{
				var sequence = _sceneModel?.CurrentSequence;
				return sequence?.TimeNormalized ?? 0f;
			}
		}

		public float ZoomFactor => _zoomFactor;
		public bool FollowTarget => _followTarget;

		public SceneCanvas( Widget parent ) : base( parent )
		{
			MouseTracking = true;
			FocusMode = FocusMode.Click;
			var scene = Scene.CreateEditorScene();
			Scene = scene;
			using ( scene.Push() )
			{
				var camera = new GameObject( true, "camera" ).GetOrAddComponent<CameraComponent>( false );
				Camera = camera;
				camera.BackgroundColor = Theme.SurfaceBackground;
				camera.Enabled = true;
				camera.ZNear = 0.1f;
				camera.ZFar = 5000f;
				camera.FieldOfView = 35f;
			}

			var world = scene.SceneWorld;
			_world = world;
			if ( world is not null )
			{
				new ScenePointLight( world, new Vector3( 120, 120, 160 ), 600, Color.White * 6 ) { ShadowsEnabled = false };
				new ScenePointLight( world, new Vector3( -150, -50, 120 ), 600, Color.White * 3 ) { ShadowsEnabled = false };
			}

			ResetCamera();
		}

		public void LoadAnimation( string vmdlResourcePath, string sequenceName )
		{
			var world = _world;
			if ( world is null )
				return;

			ClearAnimation();

			var model = Model.Load( vmdlResourcePath );
			if ( model is null || model.IsError )
				return;

			_sceneModel = new SceneModel( world, model, Transform.Zero );
			_sceneModel.UseAnimGraph = false;
			_sceneModel.ColorTint = Color.White;
			_sceneModel.Update( 0f );

			var sequenceToLoad = string.IsNullOrWhiteSpace( sequenceName ) && model.AnimationCount > 0
				? model.GetAnimationName( 0 )
				: sequenceName;
			_currentSequence = sequenceToLoad ?? string.Empty;
			var currentSequence = _sceneModel.CurrentSequence;
			if ( currentSequence is not null && !string.IsNullOrWhiteSpace( _currentSequence ) )
			{
				currentSequence.Name = _currentSequence;
			}

			if ( currentSequence is not null )
				currentSequence.TimeNormalized = 0f;

			_sceneModel.Update( 0f );
			ResetCamera();
		}

		public void ClearAnimation()
		{
			_currentSequence = string.Empty;
			_sceneModel?.Delete();
			_sceneModel = null;
			_appliedPlaybackRate = 0f;
			_focusPoint = Vector3.Zero;
			_panOffset = Vector3.Zero;
			_contentRadius = 48f;
			_hasValidFraming = false;
			_orbitDragging = false;
			_panDragging = false;
			NotifyCameraStateChanged();
		}

		public void SetPlaybackRate( float rate )
		{
			if ( _sceneModel is null )
				return;

			_appliedPlaybackRate = rate;
			_sceneModel.PlaybackRate = rate;
		}

		public void SetPaused( bool paused )
		{
			if ( _sceneModel is null )
				return;

			_appliedPlaybackRate = paused ? 0f : MathF.Max( _appliedPlaybackRate, 0.25f );
			_sceneModel.PlaybackRate = _appliedPlaybackRate;
		}

		public void SeekNormalized( float normalizedTime )
		{
			if ( _sceneModel is null )
				return;

			var currentSequence = _sceneModel.CurrentSequence;
			if ( currentSequence is null )
				return;

			currentSequence.TimeNormalized = Math.Clamp( normalizedTime, 0f, 1f );
			_sceneModel.Update( 0f );
		}

		public void ResetCamera()
		{
			_orbitYaw = DefaultYaw;
			_orbitPitch = DefaultPitch;
			_zoomFactor = DefaultZoomFactor;
			_panOffset = Vector3.Zero;
			_followTarget = true;
			FrameCharacter();
			NotifyCameraStateChanged();
		}

		public void FrameCharacter()
		{
			var camera = Camera;

			if ( _sceneModel is null )
			{
				if ( camera is not null && camera.IsValid() )
				{
					camera.WorldPosition = new Vector3( 140, -180, 80 );
					camera.WorldRotation = Rotation.LookAt( Vector3.Zero - camera.WorldPosition, Vector3.Up );
				}

				return;
			}

			var idealFocus = CalculateIdealFocusPoint();
			_focusPoint = idealFocus;
			_panOffset = Vector3.Zero;
			_hasValidFraming = true;
			UpdateCameraTransform();
			NotifyCameraStateChanged();
		}

		public void OrbitBy( float yawDelta, float pitchDelta )
		{
			_orbitYaw += yawDelta;
			_orbitPitch = Math.Clamp( _orbitPitch + pitchDelta, -75f, 75f );
			UpdateCameraTransform();
			NotifyCameraStateChanged();
		}

		public void AdjustZoom( float scaleMultiplier )
		{
			if ( scaleMultiplier <= 0f )
				return;

			_zoomFactor = Math.Clamp( _zoomFactor * scaleMultiplier, MinimumZoomFactor, MaximumZoomFactor );
			UpdateCameraTransform();
			NotifyCameraStateChanged();
		}

		public void SetZoomFactor( float zoomFactor )
		{
			_zoomFactor = Math.Clamp( zoomFactor, MinimumZoomFactor, MaximumZoomFactor );
			UpdateCameraTransform();
			NotifyCameraStateChanged();
		}

		public void SetFollowTarget( bool followTarget )
		{
			_followTarget = followTarget;
			if ( _followTarget && _sceneModel is not null )
			{
				_focusPoint = CalculateIdealFocusPoint();
				_panOffset = Vector3.Zero;
				_hasValidFraming = true;
			}

			UpdateCameraTransform();
			NotifyCameraStateChanged();
		}

		protected override void OnMousePress( MouseEvent e )
		{
			base.OnMousePress( e );
			_lastMousePosition = e.LocalPosition;

			if ( e.LeftMouseButton )
			{
				_orbitDragging = true;
				Cursor = CursorShape.ClosedHand;
			}
			else if ( e.RightMouseButton || e.MiddleMouseButton )
			{
				_panDragging = true;
				Cursor = CursorShape.SizeAll;
			}
		}

		protected override void OnMouseReleased( MouseEvent e )
		{
			base.OnMouseReleased( e );
			_orbitDragging = false;
			_panDragging = false;
			Cursor = CursorShape.Arrow;
		}

		protected override void OnMouseMove( MouseEvent e )
		{
			base.OnMouseMove( e );

			var delta = e.LocalPosition - _lastMousePosition;
			_lastMousePosition = e.LocalPosition;

			if ( _orbitDragging )
			{
				_orbitYaw -= delta.x * 0.35f;
				_orbitPitch = Math.Clamp( _orbitPitch + delta.y * 0.25f, -75f, 75f );
				UpdateCameraTransform();
				return;
			}

			var camera = Camera;
			if ( _panDragging && camera is not null && camera.IsValid() )
			{
				var distance = ComputeCameraDistance();
				var unitsPerPixel = Math.Max( distance * 0.0018f, 0.03f );
				_panOffset += (-camera.WorldRotation.Right * delta.x + camera.WorldRotation.Up * delta.y) * unitsPerPixel;
				UpdateCameraTransform();
				return;
			}
		}

		protected override void OnMouseWheel( WheelEvent e )
		{
			base.OnMouseWheel( e );
			AdjustZoom( e.Delta > 0 ? 0.9f : 1.1f );
			e.Accept();
		}

		protected override void PreFrame()
		{
			var scene = Scene;
			scene?.EditorTick( RealTime.Now, RealTime.Delta );

			if ( _sceneModel is null )
				return;

			var currentSequence = _sceneModel.CurrentSequence;
			if ( _appliedPlaybackRate > 0f && currentSequence is not null && currentSequence.TimeNormalized >= 0.999f )
			{
				currentSequence.TimeNormalized = 0f;
			}

			_sceneModel.Update( RealTime.Delta );
			UpdateFramingState();
			UpdateCameraTransform();
		}

		public override void OnDestroyed()
		{
			base.OnDestroyed();
			ClearAnimation();
			var scene = Scene;
			scene?.Destroy();
			Scene = null;
			_world = null;
		}

		private void UpdateFramingState()
		{
			if ( _sceneModel is null )
				return;

			var idealFocus = CalculateIdealFocusPoint();
			var bounds = _sceneModel.Bounds;
			var radius = Math.Max( bounds.Size.Length * 0.5f, 22f );
			_contentRadius = radius;

			if ( !_hasValidFraming )
			{
				_focusPoint = idealFocus;
				_hasValidFraming = true;
				return;
			}

			if ( _followTarget )
			{
				var lerp = Math.Clamp( RealTime.Delta * 8f, 0f, 1f );
				_focusPoint = Vector3.Lerp( _focusPoint, idealFocus, lerp );
			}
		}

		private Vector3 CalculateIdealFocusPoint()
		{
			if ( _sceneModel is null )
				return Vector3.Zero;

			var bounds = _sceneModel.Bounds;
			var focusZ = bounds.Mins.z.LerpTo( bounds.Maxs.z, 0.58f );
			return bounds.Center.WithZ( focusZ );
		}

		private float ComputeCameraDistance()
		{
			var camera = Camera;
			if ( camera is null || !camera.IsValid() )
				return 180f;

			var baseDistance = MathX.SphereCameraDistance( _contentRadius * 1.1f, camera.FieldOfView );
			var aspect = Math.Max( Size.x / Math.Max( Size.y, 1f ), 0.1f );
			if ( aspect < 1f )
			{
				baseDistance *= MathF.Sqrt( 1f / aspect );
			}

			return Math.Clamp( baseDistance * _zoomFactor, 32f, 4096f );
		}

		private void UpdateCameraTransform()
		{
			var camera = Camera;
			if ( camera is null || !camera.IsValid() )
				return;

			var lookAngle = new Angles( _orbitPitch, _orbitYaw, 0 );
			var lookRotation = lookAngle.ToRotation();
			var focus = _focusPoint + _panOffset;
			var distance = ComputeCameraDistance();
			var cameraPosition = focus + lookRotation.Backward * distance;
			camera.WorldPosition = cameraPosition;
			camera.WorldRotation = Rotation.LookAt( focus - cameraPosition, Vector3.Up );
		}

		private void NotifyCameraStateChanged()
		{
			CameraStateChanged?.Invoke();
		}
	}
}

internal sealed class RetargetSourceFacingPreviewWidget : Widget
{
	private readonly FacingCanvas _canvas;
	private readonly Label _statusLabel;

	public RetargetSourceFacingPreviewWidget( Widget parent ) : base( parent )
	{
		MinimumHeight = 280f;
		var root = Layout.Column();
		Layout = root;
		root.Margin = 0;
		root.Spacing = 6;

		var controls = new Widget( this );
		var controlsLayout = Layout.Row();
		controls.Layout = controlsLayout;
			controlsLayout.Spacing = 6;
			controlsLayout.Add( new Label( "Source Preview" ) );
			var resetButton = controlsLayout.Add( new Button( "Reset View" ) );
			if ( resetButton is not null )
			{
				resetButton.Clicked += () => _canvas?.ResetCamera();
			}
			controlsLayout.AddStretchCell();
			root.Add( controls );

		_canvas = new FacingCanvas( this );
		root.Add( _canvas, 1 );

		_statusLabel = root.Add( new Label.Body( "Scan a source FBX to inspect its facing." ) { Color = Theme.Text.WithAlpha( 0.72f ) } );
	}

	public void UpdatePreview( IReadOnlyList<NativeAuditBoneInfo>? bones, Vector3 effectiveEulerDegrees, string? message = null )
	{
		var resolvedMessage = string.IsNullOrWhiteSpace( message )
			? "Green arrow = Citizen front. Yellow arrow = current source facing. Rotate until both arrows point the same way."
			: message.Trim();
		_statusLabel.Text = resolvedMessage;
		_canvas.UpdateSkeleton( bones ?? Array.Empty<NativeAuditBoneInfo>(), effectiveEulerDegrees );
	}

	private sealed class FacingCanvas : SceneRenderingWidget
	{
		private const float DefaultYaw = 135f;
		private const float DefaultPitch = 18f;
		private const float MinimumZoomFactor = 0.6f;
		private const float MaximumZoomFactor = 2.8f;
		private static readonly Color SourceFacingColor = new Color( 0.96f, 0.79f, 0.24f );
		private static readonly Color PelvisMarkerColor = new Color( 0.98f, 0.86f, 0.36f );
		private static readonly Color HeadMarkerColor = new Color( 0.45f, 0.78f, 1.0f );

		private readonly List<(Vector3 Start, Vector3 End)> _segments = new();
		private readonly List<Vector3> _points = new();
		private Vector3 _sourceFacingArrowStart;
		private Vector3 _sourceFacingArrowEnd;
		private bool _hasSourceFacingArrow;
		private Vector3 _pelvisMarker;
		private bool _hasPelvisMarker;
		private Vector3 _headMarker;
		private bool _hasHeadMarker;
		private float _orbitYaw = DefaultYaw;
		private float _orbitPitch = DefaultPitch;
		private float _zoomFactor = 1.0f;
		private bool _orbitDragging;
		private Vector2 _lastMousePosition;
		private Vector3 _focusPoint;
		private float _contentRadius = 42f;

		public FacingCanvas( Widget parent ) : base( parent )
		{
			MinimumHeight = 230f;
			MouseTracking = true;
			FocusMode = FocusMode.Click;
			Scene = Scene.CreateEditorScene();

			using ( Scene.Push() )
			{
				var camera = new GameObject( true, "camera" ).GetOrAddComponent<CameraComponent>( false );
				Camera = camera;
				camera.BackgroundColor = Theme.SurfaceBackground;
				camera.Enabled = true;
				camera.ZNear = 0.1f;
				camera.ZFar = 5000f;
				camera.FieldOfView = 40f;

				var ambient = new GameObject( true, "light" ).GetOrAddComponent<AmbientLight>( false );
				ambient.Color = Theme.Text.WithAlpha( 0.08f );
				ambient.Enabled = true;

				var light = new GameObject( true, "light" ).GetOrAddComponent<DirectionalLight>( false );
				light.WorldRotation = Rotation.From( 35f, 35f, 0f );
				light.LightColor = Color.White;
				light.Enabled = true;
			}

			ResetCamera();
		}

		public void UpdateSkeleton( IReadOnlyList<NativeAuditBoneInfo> bones, Vector3 effectiveEulerDegrees )
		{
			_segments.Clear();
			_points.Clear();
			_hasSourceFacingArrow = false;
			_hasPelvisMarker = false;
			_hasHeadMarker = false;

			if ( bones.Count == 0 )
			{
				_focusPoint = Vector3.Zero;
				_contentRadius = 42f;
				UpdateCameraTransform();
				Update();
				return;
			}

			var upAxis = DetermineUpAxis( bones );
			var previewPoints = new Dictionary<string, Vector3>( StringComparer.OrdinalIgnoreCase );
			foreach ( var bone in bones )
			{
				var rotated = RotateSourceOrientation( bone.WorldTransform.Translation, effectiveEulerDegrees );
				var previewPoint = ConvertToPreviewSpace( rotated, upAxis );
				previewPoints[bone.Name] = previewPoint;
				_points.Add( previewPoint );
			}

			foreach ( var bone in bones )
			{
				if ( string.IsNullOrWhiteSpace( bone.ParentName ) )
					continue;

				if ( !previewPoints.TryGetValue( bone.ParentName, out var parentPoint ) || !previewPoints.TryGetValue( bone.Name, out var childPoint ) )
					continue;

				_segments.Add( (parentPoint, childPoint) );
			}

			if ( TryBuildFacingArrow( bones, upAxis, effectiveEulerDegrees, out var arrowStart, out var arrowEnd ) )
			{
				_sourceFacingArrowStart = arrowStart;
				_sourceFacingArrowEnd = arrowEnd;
				_hasSourceFacingArrow = true;
			}

			if ( TryFindPreviewMarker( bones, upAxis, effectiveEulerDegrees, IsPelvisBoneName, out var pelvisMarker ) )
			{
				_pelvisMarker = pelvisMarker;
				_hasPelvisMarker = true;
			}

			if ( TryFindPreviewMarker( bones, upAxis, effectiveEulerDegrees, IsHeadBoneName, out var headMarker ) )
			{
				_headMarker = headMarker;
				_hasHeadMarker = true;
			}

			var mins = new Vector3(
				_points.Min( point => point.x ),
				_points.Min( point => point.y ),
				_points.Min( point => point.z ) );
			var maxs = new Vector3(
				_points.Max( point => point.x ),
				_points.Max( point => point.y ),
				_points.Max( point => point.z ) );
			_focusPoint = (mins + maxs) * 0.5f;
			_contentRadius = MathF.Max( (maxs - mins).Length * 0.55f, 24f );
			UpdateCameraTransform();
			Update();
		}

		public void ResetCamera()
		{
			_orbitYaw = DefaultYaw;
			_orbitPitch = DefaultPitch;
			_zoomFactor = 1.0f;
			UpdateCameraTransform();
			Update();
		}

		protected override void OnMousePress( MouseEvent e )
		{
			base.OnMousePress( e );
			_lastMousePosition = e.LocalPosition;
			if ( e.LeftMouseButton )
			{
				_orbitDragging = true;
				Cursor = CursorShape.ClosedHand;
			}
		}

		protected override void OnMouseReleased( MouseEvent e )
		{
			base.OnMouseReleased( e );
			_orbitDragging = false;
			Cursor = CursorShape.Arrow;
		}

		protected override void OnMouseMove( MouseEvent e )
		{
			base.OnMouseMove( e );
			var delta = e.LocalPosition - _lastMousePosition;
			_lastMousePosition = e.LocalPosition;
			if ( !_orbitDragging )
				return;

			_orbitYaw -= delta.x * 0.32f;
			_orbitPitch = Math.Clamp( _orbitPitch + delta.y * 0.22f, -75f, 75f );
			UpdateCameraTransform();
			Update();
		}

		protected override void OnMouseWheel( WheelEvent e )
		{
			base.OnMouseWheel( e );
			var multiplier = e.Delta > 0 ? 0.9f : 1.1f;
			_zoomFactor = Math.Clamp( _zoomFactor * multiplier, MinimumZoomFactor, MaximumZoomFactor );
			UpdateCameraTransform();
			Update();
			e.Accept();
		}

		protected override void PreFrame()
		{
			Scene?.EditorTick( RealTime.Now, RealTime.Delta );
			DrawSkeletonPreview();
		}

		public override void OnDestroyed()
		{
			base.OnDestroyed();
			Scene?.Destroy();
			Scene = null;
		}

		private void DrawSkeletonPreview()
		{
			Gizmo.Draw.Grid( 0f, Gizmo.GridAxis.XY );
			DrawCitizenFrontGuide();

			Gizmo.Draw.LineThickness = 4f;
			Gizmo.Draw.Color = Theme.Primary.WithAlpha( 0.95f );
			foreach ( var segment in _segments )
				Gizmo.Draw.Line( segment.Start, segment.End );

			if ( _hasSourceFacingArrow )
			{
				Gizmo.Draw.LineThickness = 6f;
				Gizmo.Draw.Color = SourceFacingColor;
				Gizmo.Draw.Arrow( _sourceFacingArrowStart, _sourceFacingArrowEnd, 11f, 7f );
			}

			if ( _hasPelvisMarker )
			{
				Gizmo.Draw.Color = PelvisMarkerColor;
				Gizmo.Draw.LineSphere( new Sphere( _pelvisMarker, 1.15f ), 10 );
			}

			if ( _hasHeadMarker )
			{
				Gizmo.Draw.Color = HeadMarkerColor;
				Gizmo.Draw.LineSphere( new Sphere( _headMarker, 1.2f ), 10 );
			}

			Gizmo.Draw.Color = Theme.Text.WithAlpha( 0.86f );
			foreach ( var point in _points )
				Gizmo.Draw.LineSphere( new Sphere( point, 0.65f ), 6 );
		}

		private void DrawCitizenFrontGuide()
		{
			var groundOrigin = _hasPelvisMarker
				? new Vector3( _pelvisMarker.x, _pelvisMarker.y, _pelvisMarker.z - MathF.Max( _contentRadius * 0.46f, 18f ) )
				: Vector3.Zero;
			var arrowLength = MathF.Max( 42f, _contentRadius * 0.85f );
			Gizmo.Draw.LineThickness = 6f;
			Gizmo.Draw.Color = Theme.Green;
			Gizmo.Draw.Arrow( groundOrigin + Vector3.Up * 3f, groundOrigin + Vector3.Up * 3f + Vector3.Forward * arrowLength, 13f, 8f );
		}

		private void UpdateCameraTransform()
		{
			var camera = Camera;
			if ( camera is null || !camera.IsValid() )
				return;

			var distance = Math.Clamp( MathX.SphereCameraDistance( _contentRadius * 1.1f, camera.FieldOfView ) * _zoomFactor, 28f, 2048f );
			var lookRotation = new Angles( _orbitPitch, _orbitYaw, 0f ).ToRotation();
			var focus = _focusPoint.WithZ( _focusPoint.z + MathF.Max( _contentRadius * 0.08f, 6f ) );
			var cameraPosition = focus + lookRotation.Backward * distance;
			camera.WorldPosition = cameraPosition;
			camera.WorldRotation = Rotation.LookAt( focus - cameraPosition, Vector3.Up );
		}

		private static int DetermineUpAxis( IReadOnlyList<NativeAuditBoneInfo> bones )
		{
			if ( bones.Count == 0 )
				return 2;

			if ( TryInferUpAxisFromBodyLandmarks( bones, out var inferredUpAxis ) )
				return inferredUpAxis;

			static float Range( IEnumerable<float> values ) => values.Max() - values.Min();
			var xRange = Range( bones.Select( bone => bone.WorldTransform.Translation.X ) );
			var yRange = Range( bones.Select( bone => bone.WorldTransform.Translation.Y ) );
			var zRange = Range( bones.Select( bone => bone.WorldTransform.Translation.Z ) );
			if ( xRange >= yRange && xRange >= zRange )
				return 0;
			if ( yRange >= zRange )
				return 1;
			return 2;
		}

		private static int DetermineUpAxis( IReadOnlyList<NativeBoneInfo> bones )
		{
			if ( bones.Count == 0 )
				return 2;

			var bonesByName = bones.ToDictionary( bone => bone.Name, StringComparer.OrdinalIgnoreCase );
			var worldTransforms = new Dictionary<string, WorldTransform>( StringComparer.OrdinalIgnoreCase );
			foreach ( var bone in bones )
				ComputeRestWorldTransform( bone, bonesByName, worldTransforms );

			if ( TryFindWorldPoint( worldTransforms, IsPelvisBoneName, out var pelvis )
				&& (TryFindWorldPoint( worldTransforms, IsHeadBoneName, out var head )
					|| TryFindWorldPoint( worldTransforms, IsNeckBoneName, out head )
					|| TryFindWorldPoint( worldTransforms, IsChestBoneName, out head )
					|| TryFindWorldPoint( worldTransforms, IsSpineBoneName, out head )) )
			{
				var vertical = head - pelvis;
				var absX = MathF.Abs( vertical.X );
				var absY = MathF.Abs( vertical.Y );
				var absZ = MathF.Abs( vertical.Z );
				if ( absX >= absY && absX >= absZ )
					return 0;
				if ( absY >= absZ )
					return 1;
				return 2;
			}

			static float Range( IEnumerable<float> values ) => values.Max() - values.Min();
			var translations = worldTransforms.Values.Select( transform => transform.Translation ).ToList();
			var xRange = Range( translations.Select( point => point.X ) );
			var yRange = Range( translations.Select( point => point.Y ) );
			var zRange = Range( translations.Select( point => point.Z ) );
			if ( xRange >= yRange && xRange >= zRange )
				return 0;
			if ( yRange >= zRange )
				return 1;
			return 2;
		}

		private static WorldTransform ComputeRestWorldTransform(
			NativeBoneInfo bone,
			IReadOnlyDictionary<string, NativeBoneInfo> bonesByName,
			Dictionary<string, WorldTransform> worldTransforms )
		{
			if ( worldTransforms.TryGetValue( bone.Name, out var cached ) )
				return cached;

			var local = bone.LocalTransform;
			WorldTransform result;
			if ( string.IsNullOrWhiteSpace( bone.ParentName ) || !bonesByName.TryGetValue( bone.ParentName, out var parentBone ) )
			{
				result = new WorldTransform( local.Translation, local.Rotation );
			}
			else
			{
				var parent = ComputeRestWorldTransform( parentBone, bonesByName, worldTransforms );
				result = new WorldTransform(
					parent.Translation + NVector3.Transform( local.Translation, parent.Rotation ),
					RetargetMath.Normalize( parent.Rotation * local.Rotation ) );
			}

			worldTransforms[bone.Name] = result;
			return result;
		}

		private static bool TryFindWorldPoint(
			IReadOnlyDictionary<string, WorldTransform> worldTransforms,
			Func<string, bool> predicate,
			out NVector3 point )
		{
			foreach ( var pair in worldTransforms )
			{
				if ( predicate( pair.Key ) )
				{
					point = pair.Value.Translation;
					return true;
				}
			}

			point = default;
			return false;
		}

		private static bool TryInferUpAxisFromBodyLandmarks( IReadOnlyList<NativeAuditBoneInfo> bones, out int upAxis )
		{
			upAxis = 2;

			if ( !TryFindWorldPoint( bones, IsPelvisBoneName, out var pelvis ) )
				return false;

			if ( !TryFindWorldPoint( bones, IsHeadBoneName, out var head )
				&& !TryFindWorldPoint( bones, IsNeckBoneName, out head )
				&& !TryFindWorldPoint( bones, IsChestBoneName, out head )
				&& !TryFindWorldPoint( bones, IsSpineBoneName, out head ) )
			{
				return false;
			}

			var vertical = head - pelvis;
			var absX = MathF.Abs( vertical.X );
			var absY = MathF.Abs( vertical.Y );
			var absZ = MathF.Abs( vertical.Z );
			if ( absX >= absY && absX >= absZ )
			{
				upAxis = 0;
				return true;
			}

			if ( absY >= absZ )
			{
				upAxis = 1;
				return true;
			}

			upAxis = 2;
			return true;
		}

		private static bool TryBuildFacingArrow( IReadOnlyList<NativeAuditBoneInfo> bones, int upAxis, Vector3 effectiveEulerDegrees, out Vector3 start, out Vector3 end )
		{
			start = default;
			end = default;

			if ( !TryFindWorldPoint( bones, IsPelvisBoneName, out var pelvis )
				|| !TryFindWorldPoint( bones, name => IsUpperLegBoneName( name, leftSide: true ), out var leftUpperLeg )
				|| !TryFindWorldPoint( bones, name => IsUpperLegBoneName( name, leftSide: false ), out var rightUpperLeg )
				|| !TryFindWorldPoint( bones, name => IsShoulderLikeBoneName( name, leftSide: true ), out var leftShoulder )
				|| !TryFindWorldPoint( bones, name => IsShoulderLikeBoneName( name, leftSide: false ), out var rightShoulder ) )
			{
				return false;
			}

			var hipsMid = (leftUpperLeg + rightUpperLeg) * 0.5f;
			var shouldersMid = (leftShoulder + rightShoulder) * 0.5f;
			var up = NormalizeOrFallback( shouldersMid - hipsMid, GetUpAxisVector( upAxis ) );
			var rightHint = NormalizeOrFallback( ((rightShoulder - leftShoulder) + (rightUpperLeg - leftUpperLeg)) * 0.5f, NVector3.UnitX );
			var forward = NormalizeOrFallback( NVector3.Cross( up, rightHint ), GetForwardFallbackVector( upAxis ) );

			var rotatedPelvis = RotateSourceOrientation( pelvis, effectiveEulerDegrees );
			var rotatedForward = RotateSourceOrientation( forward, effectiveEulerDegrees );
			var previewPelvis = ConvertToPreviewSpace( rotatedPelvis, upAxis );
			var previewForward = ConvertDirectionToPreviewSpace( rotatedForward, upAxis );
			var previewUp = GetPreviewUpAxisVector();
			var horizontalForward = previewForward.WithZ( 0f );
			previewForward = horizontalForward.Length > 0.001f ? horizontalForward.Normal : Vector3.Forward;

			var previewRight = previewUp.Cross( previewForward );
			previewRight = previewRight.Length > 0.001f ? previewRight.Normal : Vector3.Right;
			var previewFloorZ = bones
				.Select( bone => ConvertToPreviewSpace( RotateSourceOrientation( bone.WorldTransform.Translation, effectiveEulerDegrees ), upAxis ).z )
				.Min();
			var previewLeftShoulder = ConvertToPreviewSpace( RotateSourceOrientation( leftShoulder, effectiveEulerDegrees ), upAxis );
			var previewRightShoulder = ConvertToPreviewSpace( RotateSourceOrientation( rightShoulder, effectiveEulerDegrees ), upAxis );
			var shoulderWidth = MathF.Max( (previewRightShoulder - previewLeftShoulder).Length, 8f );
			var sideOffset = MathF.Max( shoulderWidth * 1.15f, 12f );
			var arrowLength = MathF.Max( shoulderWidth * 2.6f, 28f );

			// Keep the facing arrow as a floor marker beside the rig, not attached to the pelvis.
			start = new Vector3( previewPelvis.x, previewPelvis.y, previewFloorZ + 2.5f ) + previewRight * sideOffset - previewForward * 4f;
			end = start + previewForward * arrowLength;
			return true;
		}

		private static bool TryFindPreviewMarker(
			IReadOnlyList<NativeAuditBoneInfo> bones,
			int upAxis,
			Vector3 effectiveEulerDegrees,
			Func<string, bool> predicate,
			out Vector3 point )
		{
			point = default;
			if ( !TryFindWorldPoint( bones, predicate, out var worldPoint ) )
				return false;

			var rotated = RotateSourceOrientation( worldPoint, effectiveEulerDegrees );
			point = ConvertToPreviewSpace( rotated, upAxis );
			return true;
		}

		private static bool TryFindWorldPoint( IReadOnlyList<NativeAuditBoneInfo> bones, Func<string, bool> predicate, out NVector3 point )
		{
			point = default;
			foreach ( var bone in bones )
			{
				if ( predicate( bone.Name ) )
				{
					point = bone.WorldTransform.Translation;
					return true;
				}
			}

			return false;
		}

		private static NVector3 RotateSourceOrientation( NVector3 point, Vector3 eulerDegrees )
		{
			// The Blender backend applies sourceFacingEulerDegrees as native XYZ Euler rotations.
			// Keep this preview in the same convention so "looks aligned" matches the actual solve.
			var rotated = RotateAroundX( point, MathF.PI / 180f * eulerDegrees.x );
			rotated = RotateAroundY( rotated, MathF.PI / 180f * eulerDegrees.y );
			rotated = RotateAroundZ( rotated, MathF.PI / 180f * eulerDegrees.z );
			return rotated;
		}

		private static NVector3 RotateAroundX( NVector3 point, float radians )
		{
			var sin = MathF.Sin( radians );
			var cos = MathF.Cos( radians );
			return new NVector3(
				point.X,
				point.Y * cos - point.Z * sin,
				point.Y * sin + point.Z * cos );
		}

		private static NVector3 RotateAroundY( NVector3 point, float radians )
		{
			var sin = MathF.Sin( radians );
			var cos = MathF.Cos( radians );
			return new NVector3(
				point.X * cos + point.Z * sin,
				point.Y,
				-point.X * sin + point.Z * cos );
		}

		private static NVector3 RotateAroundZ( NVector3 point, float radians )
		{
			var sin = MathF.Sin( radians );
			var cos = MathF.Cos( radians );
			return new NVector3(
				point.X * cos - point.Y * sin,
				point.X * sin + point.Y * cos,
				point.Z );
		}

		private static Vector3 ConvertDirectionToPreviewSpace( NVector3 direction, int upAxis )
		{
			var converted = ConvertToPreviewSpace( direction, upAxis );
			return converted.Length.AlmostEqual( 0f ) ? Vector3.Forward : converted.Normal;
		}

		private static Vector3 GetPreviewUpAxisVector()
		{
			return Vector3.Up;
		}

		private static Vector3 ConvertToPreviewSpace( NVector3 point, int upAxis )
		{
			// s&box/Citizen preview uses X as front and Z as up. Source FBX files vary:
			// Unity-style rigs are usually Y-up/Z-forward, Blender-style rigs are Z-up/Y-forward.
			// Map the inferred source up axis to preview Z and the likely source forward axis to preview X.
			return upAxis switch
			{
				0 => new Vector3( point.Y, point.Z, point.X ),
				1 => new Vector3( point.Z, point.X, point.Y ),
				_ => new Vector3( point.Y, point.X, point.Z )
			};
		}

		private static NVector3 NormalizeOrFallback( NVector3 value, NVector3 fallback )
		{
			return value.LengthSquared() > 0.000001f ? NVector3.Normalize( value ) : fallback;
		}

		private static NVector3 GetUpAxisVector( int upAxis )
		{
			return upAxis switch
			{
				0 => NVector3.UnitX,
				1 => NVector3.UnitY,
				_ => NVector3.UnitZ
			};
		}

		private static NVector3 GetForwardFallbackVector( int upAxis )
		{
			return upAxis switch
			{
				1 => NVector3.UnitZ,
				_ => NVector3.UnitY
			};
		}

		private static bool IsPelvisBoneName( string boneName )
		{
			var normalized = NormalizeBoneName( boneName );
			return normalized.Contains( "pelvis" ) || normalized.Equals( "hips" ) || normalized.Equals( "hip" ) || normalized.Contains( "roothips" );
		}

		private static bool IsHeadBoneName( string boneName )
		{
			var normalized = NormalizeBoneName( boneName );
			return normalized.Contains( "head" );
		}

		private static bool IsNeckBoneName( string boneName )
		{
			var normalized = NormalizeBoneName( boneName );
			return normalized.Contains( "neck" );
		}

		private static bool IsChestBoneName( string boneName )
		{
			var normalized = NormalizeBoneName( boneName );
			return normalized.Contains( "chest" )
				|| normalized.Contains( "upperchest" );
		}

		private static bool IsSpineBoneName( string boneName )
		{
			var normalized = NormalizeBoneName( boneName );
			return normalized.Contains( "spine" );
		}

		private static bool IsUpperLegBoneName( string boneName, bool leftSide )
		{
			var normalized = NormalizeBoneName( boneName );
			if ( !MatchesSide( normalized, leftSide ) )
				return false;

			return normalized.Contains( "thigh" )
				|| normalized.Contains( "upleg" )
				|| normalized.Contains( "upperleg" )
				|| normalized.Contains( "legupper" )
				|| normalized.Contains( "upperleg" );
		}

		private static bool IsShoulderLikeBoneName( string boneName, bool leftSide )
		{
			var normalized = NormalizeBoneName( boneName );
			if ( !MatchesSide( normalized, leftSide ) )
				return false;

			return normalized.Contains( "clavicle" )
				|| normalized.Contains( "shoulder" )
				|| normalized.Contains( "upperarm" )
				|| normalized.Contains( "armupper" );
		}

		private static bool MatchesSide( string normalizedBoneName, bool leftSide )
		{
			return leftSide ? LooksLikeLeft( normalizedBoneName ) : LooksLikeRight( normalizedBoneName );
		}

		private static bool LooksLikeLeft( string normalizedBoneName )
		{
			return normalizedBoneName.Contains( "left" )
				|| normalizedBoneName.EndsWith( "l" )
				|| normalizedBoneName.Contains( "lft" );
		}

		private static bool LooksLikeRight( string normalizedBoneName )
		{
			return normalizedBoneName.Contains( "right" )
				|| normalizedBoneName.EndsWith( "r" )
				|| normalizedBoneName.Contains( "rgt" );
		}

		private static string NormalizeBoneName( string boneName )
		{
			if ( string.IsNullOrWhiteSpace( boneName ) )
				return string.Empty;

			var builder = new System.Text.StringBuilder( boneName.Length );
			foreach ( var ch in boneName )
			{
				if ( char.IsLetterOrDigit( ch ) )
					builder.Append( char.ToLowerInvariant( ch ) );
			}

			return builder.ToString();
		}
	}
}

internal sealed class RetargetLiveCompareWidget : Widget
{
	private readonly Label _summaryLabel;
	private readonly RetargetLivePreviewWidget _sourcePreview;
	private readonly RetargetLivePreviewWidget _resultPreview;

	public RetargetLiveCompareWidget( Widget parent ) : base( parent )
	{
		var rootLayout = Layout.Column();
		Layout = rootLayout;
		rootLayout.Margin = 0;
		rootLayout.Spacing = 6;

		_summaryLabel = rootLayout.Add( new Label( "Compare preview is waiting for a source clip and a retarget result." ) { WordWrap = true } );

		var split = rootLayout.Add( new Splitter( this ), 1 );
		split.IsHorizontal = true;

		var sourceHost = CreatePreviewPane( split, "Source" );
		_sourcePreview = new RetargetLivePreviewWidget( sourceHost, compactMode: true );
		sourceHost.Layout.Add( _sourcePreview, 1 );
		split.AddWidget( sourceHost );
		split.SetStretch( 0, 1 );

		var resultHost = CreatePreviewPane( split, "Result" );
		_resultPreview = new RetargetLivePreviewWidget( resultHost, compactMode: true );
		resultHost.Layout.Add( _resultPreview, 1 );
		split.AddWidget( resultHost );
		split.SetStretch( 1, 1 );
	}

	public void LoadAnimations(
		string sourceVmdlResourcePath,
		string sourceSequenceName,
		string resultVmdlResourcePath,
		string resultSequenceName )
	{
		var hasSource = !string.IsNullOrWhiteSpace( sourceVmdlResourcePath );
		var hasResult = !string.IsNullOrWhiteSpace( resultVmdlResourcePath );

		if ( hasSource )
			_sourcePreview.LoadAnimation( sourceVmdlResourcePath, sourceSequenceName );
		else
			_sourcePreview.ClearAnimation( "Source preview is waiting for a generated source clip preview." );

		if ( hasResult )
			_resultPreview.LoadAnimation( resultVmdlResourcePath, resultSequenceName );
		else
			_resultPreview.ClearAnimation( "Result preview is waiting for a generated Citizen animation." );

		_summaryLabel.Text = (hasSource, hasResult) switch
		{
			(true, true) => "Live compare is showing the source clip on the left and the retargeted Citizen result on the right.",
			(true, false) => "Source clip is ready, but the retargeted Citizen result is not available yet.",
			(false, true) => "Retargeted Citizen result is ready, but the source clip preview is not available yet.",
			_ => "Compare preview is waiting for a source clip and a retarget result."
		};
	}

	public void ClearAnimations( string message )
	{
		_sourcePreview.ClearAnimation( message );
		_resultPreview.ClearAnimation( message );
		_summaryLabel.Text = string.IsNullOrWhiteSpace( message )
			? "Compare preview is waiting for a source clip and a retarget result."
			: message;
	}

	private static Widget CreatePreviewPane( Widget parent, string title )
	{
		var pane = new Widget( parent );
		pane.Layout = Layout.Column();
		pane.Layout.Margin = 0;
		pane.Layout.Spacing = 6;
		pane.Layout.Add( new Label( title ) );
		return pane;
	}
}

internal sealed class RetargetVideoPreviewWidget : Widget
{
	private Widget? _content;
	private string _currentPath = string.Empty;

	public string CurrentPath => _currentPath;

	public RetargetVideoPreviewWidget( Widget parent ) : base( parent )
	{
		var rootLayout = Layout.Column();
		Layout = rootLayout;
		rootLayout.Margin = 0;
		rootLayout.Spacing = 6;
		ShowPlaceholder( "No video preview has been generated yet." );
	}

	public void LoadVideo( string absolutePath )
	{
		_currentPath = absolutePath ?? string.Empty;
		if ( string.IsNullOrWhiteSpace( absolutePath ) || !File.Exists( absolutePath ) )
		{
			ShowPlaceholder( "No video preview is available for this run." );
			return;
		}

		_content?.Destroy();
		var url = new Uri( absolutePath ).AbsoluteUri;
		_content = new VideoWidget( this, url );
		Layout?.Add( _content, 1 );
	}

	private void ShowPlaceholder( string message )
	{
		_currentPath = string.Empty;
		_content?.Destroy();
		var label = new Label( message )
		{
			WordWrap = true,
			Alignment = TextFlag.Center
		};
		_content = label;
		Layout?.Add( _content, 1 );
	}
}