diff --git a/src/Beutl.Controls/Curves/CurveEditor.cs b/src/Beutl.Controls/Curves/CurveEditor.cs new file mode 100644 index 000000000..0b538c8dd --- /dev/null +++ b/src/Beutl.Controls/Curves/CurveEditor.cs @@ -0,0 +1,433 @@ +#nullable enable + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; + +using Beutl.Graphics; + +using AvaPoint = Avalonia.Point; +using AvaRect = Avalonia.Rect; +using BtlPoint = Beutl.Graphics.Point; + +namespace Beutl.Controls.Curves; + +public class CurveEditor : Control +{ + public static readonly StyledProperty?> PointsProperty = + AvaloniaProperty.Register?>(nameof(Points)); + + public static readonly StyledProperty IsReadOnlyProperty = + AvaloniaProperty.Register(nameof(IsReadOnly)); + + public static readonly StyledProperty VisualizationProperty = + AvaloniaProperty.Register(nameof(Visualization), CurveVisualization.None); + + public static readonly StyledProperty VisualizationRendererProperty = + AvaloniaProperty.Register(nameof(VisualizationRenderer)); + + private static readonly IPen s_curvePen = new Pen(new SolidColorBrush(Color.FromArgb(200, 0, 122, 204)), 2).ToImmutable(); + private static readonly IPen s_axisPen = new Pen(new SolidColorBrush(Color.FromArgb(120, 255, 255, 255)), 1).ToImmutable(); + private static readonly IPen s_handleLinePen = new Pen(new SolidColorBrush(Color.FromArgb(150, 255, 200, 100)), 1).ToImmutable(); + private static readonly IBrush s_handleBrush = new SolidColorBrush(Color.FromArgb(200, 255, 200, 100)).ToImmutable(); + + private int _draggingIndex = -1; + private DragTarget _dragTarget = DragTarget.None; + private int _selectedIndex = -1; + + private enum DragTarget + { + None, + Point, + LeftHandle, + RightHandle + } + + public event EventHandler? DragStarted; + + public event EventHandler? DragCompleted; + + static CurveEditor() + { + AffectsRender(PointsProperty); + } + + public IList? Points + { + get => GetValue(PointsProperty); + set => SetValue(PointsProperty, value); + } + + public bool IsReadOnly + { + get => GetValue(IsReadOnlyProperty); + set => SetValue(IsReadOnlyProperty, value); + } + + public CurveVisualization Visualization + { + get => GetValue(VisualizationProperty); + set => SetValue(VisualizationProperty, value); + } + + public CurveVisualizationRenderer? VisualizationRenderer + { + get => GetValue(VisualizationRendererProperty); + set => SetValue(VisualizationRendererProperty, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == VisualizationRendererProperty) + { + if (change.OldValue is CurveVisualizationRenderer oldRenderer) + oldRenderer.Updated -= OnRendererUpdated; + + if (change.NewValue is CurveVisualizationRenderer newRenderer) + newRenderer.Updated += OnRendererUpdated; + + InvalidateVisual(); + } + } + + private void OnRendererUpdated(object? sender, EventArgs e) + { + InvalidateVisual(); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (Points is null || IsReadOnly) return; + + var pos = e.GetPosition(this); + var norm = Normalize(pos); + var pointerPoint = e.GetCurrentPoint(this); + + var (index, target) = HitTest(pos); + + if (index >= 0) + { + if (pointerPoint.Properties.IsRightButtonPressed) + { + if (target == DragTarget.LeftHandle || target == DragTarget.RightHandle) + { + var point = Points[index]; + bool isHandleDefault = target == DragTarget.LeftHandle + ? point.LeftHandle == default + : point.RightHandle == default; + + if (!isHandleDefault) + { + // ハンドルをリセット + DragStarted?.Invoke(this, EventArgs.Empty); + Points[index] = target == DragTarget.LeftHandle + ? point.WithLeftHandle(default) + : point.WithRightHandle(default); + DragCompleted?.Invoke(this, EventArgs.Empty); + InvalidateVisual(); + return; + } + + target = DragTarget.Point; + } + + if (target == DragTarget.Point && index > 0 && index < Points.Count - 1) + { + // 端点は削除不可 + DragStarted?.Invoke(this, EventArgs.Empty); + Points.RemoveAt(index); + _selectedIndex = -1; + DragCompleted?.Invoke(this, EventArgs.Empty); + InvalidateVisual(); + } + } + else if (pointerPoint.Properties.IsLeftButtonPressed) + { + _draggingIndex = index; + _dragTarget = target; + _selectedIndex = index; + e.Pointer.Capture(this); + DragStarted?.Invoke(this, EventArgs.Empty); + InvalidateVisual(); + } + } + else if (pointerPoint.Properties.IsLeftButtonPressed) + { + DragStarted?.Invoke(this, EventArgs.Empty); + index = InsertPoint(norm); + _selectedIndex = index; + InvalidateVisual(); + + _draggingIndex = index; + _dragTarget = DragTarget.Point; + e.Pointer.Capture(this); + } + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (Points is null || IsReadOnly) return; + + if (_draggingIndex >= 0 && _dragTarget != DragTarget.None) + { + var norm = Normalize(e.GetPosition(this)); + var point = Points[_draggingIndex]; + + switch (_dragTarget) + { + case DragTarget.Point: + norm = new BtlPoint(Math.Clamp(norm.X, 0, 1), Math.Clamp(norm.Y, 0, 1)); + if (_draggingIndex == 0) + norm = new BtlPoint(0, norm.Y); + else if (_draggingIndex == Points.Count - 1) + norm = new BtlPoint(1, norm.Y); + + Points[_draggingIndex] = point.WithPoint(norm); + SortPoints(); + break; + + case DragTarget.LeftHandle: + var leftOffsetX = Math.Min(0, norm.X - point.Point.X); // 左側のみ + var leftOffset = new BtlPoint(leftOffsetX, norm.Y - point.Point.Y); + Points[_draggingIndex] = point.WithLeftHandle(leftOffset); + break; + + case DragTarget.RightHandle: + var rightOffsetX = Math.Max(0, norm.X - point.Point.X); // 右側のみ + var rightOffset = new BtlPoint(rightOffsetX, norm.Y - point.Point.Y); + Points[_draggingIndex] = point.WithRightHandle(rightOffset); + break; + } + + InvalidateVisual(); + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (_draggingIndex >= 0) + { + e.Pointer.Capture(null); + DragCompleted?.Invoke(this, EventArgs.Empty); + } + + _draggingIndex = -1; + _dragTarget = DragTarget.None; + } + + private (int Index, DragTarget Target) HitTest(AvaPoint screenPos) + { + if (Points is null) return (-1, DragTarget.None); + + const double pointRadius = 6; + const double handleRadius = 3; + + // 選択されたポイントのハンドルを優先的にチェック + if (_selectedIndex >= 0 && _selectedIndex < Points.Count) + { + var selectedPoint = Points[_selectedIndex]; + + // 左ハンドル + var leftHandlePos = Denormalize(selectedPoint.AbsoluteLeftHandle); + if (Math.Abs(leftHandlePos.X - screenPos.X) <= handleRadius && + Math.Abs(leftHandlePos.Y - screenPos.Y) <= handleRadius && + _selectedIndex != 0) + { + return (_selectedIndex, DragTarget.LeftHandle); + } + + // 右ハンドル + var rightHandlePos = Denormalize(selectedPoint.AbsoluteRightHandle); + if (Math.Abs(rightHandlePos.X - screenPos.X) <= handleRadius && + Math.Abs(rightHandlePos.Y - screenPos.Y) <= handleRadius && + _selectedIndex != Points.Count - 1) + { + return (_selectedIndex, DragTarget.RightHandle); + } + } + + // メインポイントをチェック + for (int i = 0; i < Points.Count; i++) + { + var pos = Denormalize(Points[i].Point); + if (Math.Abs(pos.X - screenPos.X) <= pointRadius && Math.Abs(pos.Y - screenPos.Y) <= pointRadius) + { + return (i, DragTarget.Point); + } + } + + return (-1, DragTarget.None); + } + + private int InsertPoint(BtlPoint norm) + { + if (Points is null) return -1; + + int index = 0; + while (index < Points.Count && Points[index].Point.X < norm.X) + { + index++; + } + + // 隣接するポイントを参考にハンドルを計算 + BtlPoint leftHandle = default; + BtlPoint rightHandle = default; + + if (index > 0 && index < Points.Count) + { + // 前後のポイントがある場合、その方向に基づいてハンドルを設定 + var prevPoint = Points[index - 1].Point; + var nextPoint = Points[index].Point; + + // 前後のポイントへの距離の1/4をハンドルの長さとする + float leftDist = (norm.X - prevPoint.X) * 0.25f; + float rightDist = (nextPoint.X - norm.X) * 0.25f; + + // 傾きを計算 + float slope = (nextPoint.Y - prevPoint.Y) / (nextPoint.X - prevPoint.X); + + leftHandle = new BtlPoint(-leftDist, -leftDist * slope); + rightHandle = new BtlPoint(rightDist, rightDist * slope); + } + else if (index > 0) + { + // 前のポイントのみある場合 + var prevPoint = Points[index - 1].Point; + float dist = (norm.X - prevPoint.X) * 0.25f; + leftHandle = new BtlPoint(-dist, 0); + } + else if (index < Points.Count) + { + // 次のポイントのみある場合 + var nextPoint = Points[index].Point; + float dist = (nextPoint.X - norm.X) * 0.25f; + rightHandle = new BtlPoint(dist, 0); + } + + Points.Insert(index, new CurveControlPoint(norm, leftHandle, rightHandle)); + return index; + } + + private void SortPoints() + { + if (Points is null) return; + + var sorted = Points.OrderBy(p => p.Point.X).ToArray(); + for (int i = 0; i < sorted.Length; i++) + { + Points[i] = sorted[i]; + } + + // Update selected index after sorting + if (_selectedIndex >= 0 && _draggingIndex >= 0) + { + for (int i = 0; i < sorted.Length; i++) + { + if (Points[i].Point == sorted[_draggingIndex].Point) + { + _selectedIndex = i; + _draggingIndex = i; + break; + } + } + } + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var bounds = new AvaRect(Bounds.Size); + + VisualizationRenderer?.Draw(context, bounds, Visualization); + + // Draw grid + for (int i = 1; i < 4; i++) + { + double x = bounds.Width / 4 * i; + double y = bounds.Height / 4 * i; + context.DrawLine(s_axisPen, new AvaPoint(x, 0), new AvaPoint(x, bounds.Height)); + context.DrawLine(s_axisPen, new AvaPoint(0, y), new AvaPoint(bounds.Width, y)); + } + + if (Points is { Count: > 0 }) + { + // Draw curve + if (Points.Count > 1) + { + var geometry = new StreamGeometry(); + using (var gctx = geometry.Open()) + { + var firstPoint = Denormalize(Points[0].Point); + gctx.BeginFigure(firstPoint, false); + + for (int i = 1; i < Points.Count; i++) + { + var prev = Points[i - 1]; + var curr = Points[i]; + + if (prev.HasHandles || curr.HasHandles) + { + // Draw cubic Bezier + var cp1 = Denormalize(prev.AbsoluteRightHandle); + var cp2 = Denormalize(curr.AbsoluteLeftHandle); + var endPoint = Denormalize(curr.Point); + gctx.CubicBezierTo(cp1, cp2, endPoint); + } + else + { + // Draw line + gctx.LineTo(Denormalize(curr.Point)); + } + } + + gctx.EndFigure(false); + } + + context.DrawGeometry(null, s_curvePen, geometry); + } + + // Draw handles for selected point + if (_selectedIndex >= 0 && _selectedIndex < Points.Count) + { + var selectedPoint = Points[_selectedIndex]; + var mainPos = Denormalize(selectedPoint.Point); + + // Draw left handle + var leftHandlePos = Denormalize(selectedPoint.AbsoluteLeftHandle); + context.DrawLine(s_handleLinePen, mainPos, leftHandlePos); + context.DrawEllipse(s_handleBrush, s_handleLinePen, leftHandlePos, 4, 4); + + // Draw right handle + var rightHandlePos = Denormalize(selectedPoint.AbsoluteRightHandle); + context.DrawLine(s_handleLinePen, mainPos, rightHandlePos); + context.DrawEllipse(s_handleBrush, s_handleLinePen, rightHandlePos, 4, 4); + } + + // Draw main points + for (int i = 0; i < Points.Count; i++) + { + var pt = Denormalize(Points[i].Point); + var brush = i == _selectedIndex ? Brushes.Yellow : Brushes.White; + context.DrawEllipse(brush, s_curvePen, pt, 4, 4); + } + } + } + + private BtlPoint Normalize(AvaPoint pos) + { + if (Bounds.Width <= 0 || Bounds.Height <= 0) return default; + return new BtlPoint((float)(pos.X / Bounds.Width), (float)(1 - (pos.Y / Bounds.Height))); + } + + private AvaPoint Denormalize(BtlPoint pos) + { + return new AvaPoint(pos.X * Bounds.Width, (1 - pos.Y) * Bounds.Height); + } +} diff --git a/src/Beutl.Controls/Curves/CurveVisualization.cs b/src/Beutl.Controls/Curves/CurveVisualization.cs new file mode 100644 index 000000000..04f94f941 --- /dev/null +++ b/src/Beutl.Controls/Curves/CurveVisualization.cs @@ -0,0 +1,15 @@ +namespace Beutl.Controls.Curves; + +public enum CurveVisualization +{ + None, + Master, + Red, + Green, + Blue, + HueVsHue, + HueVsSaturation, + HueVsLuminance, + LuminanceVsSaturation, + SaturationVsSaturation, +} diff --git a/src/Beutl.Controls/Curves/CurveVisualizationRenderer.cs b/src/Beutl.Controls/Curves/CurveVisualizationRenderer.cs new file mode 100644 index 000000000..6a917ac41 --- /dev/null +++ b/src/Beutl.Controls/Curves/CurveVisualizationRenderer.cs @@ -0,0 +1,403 @@ +#nullable enable + +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Media.Immutable; +using Avalonia.Platform; + +namespace Beutl.Controls.Curves; + +[Flags] +public enum HistogramCategory +{ + None = 0, + Rgb = 1, + Hue = 2, + Luminance = 4, + Saturation = 8, + All = Rgb | Hue | Luminance | Saturation +} + +public sealed class CurveVisualizationRenderer +{ + private readonly int[] _rHist = new int[256]; + private readonly int[] _gHist = new int[256]; + private readonly int[] _bHist = new int[256]; + private readonly int[] _combinedHist = new int[256]; + private readonly int[] _hueHist = new int[360]; + private readonly int[] _lumaHist = new int[256]; + private readonly int[] _satHist = new int[256]; + private int _histMax; + private int _hueHistMax; + private int _lumaHistMax; + private int _satHistMax; + + private static readonly ImmutableSolidColorBrush s_blackOverlayBrush = + new(Colors.Black, 0.55); + + private static readonly ImmutableSolidColorBrush s_whiteBarBrush = + new(Color.FromArgb(100, 255, 255, 255)); + + private static readonly ImmutableSolidColorBrush s_masterBarBrush = + new(Color.FromArgb(140, 255, 255, 255)); + + private static readonly ImmutableSolidColorBrush s_redBarBrush = + new(Color.FromArgb(140, 255, 0, 0)); + + private static readonly ImmutableSolidColorBrush s_greenBarBrush = + new(Color.FromArgb(140, 0, 255, 0)); + + private static readonly ImmutableSolidColorBrush s_blueBarBrush = + new(Color.FromArgb(140, 30, 144, 255)); + + private static readonly IBrush s_hueGradientBrush; + private static readonly IBrush s_luminanceGradientBrush; + private static readonly IBrush s_masterBackBrush; + private static readonly IBrush s_redBackBrush; + private static readonly IBrush s_greenBackBrush; + private static readonly IBrush s_blueBackBrush; + + static CurveVisualizationRenderer() + { + s_hueGradientBrush = new LinearGradientBrush + { + GradientStops = + [ + new GradientStop(Colors.Red, 0.0), + new GradientStop(Colors.Yellow, 1.0 / 6), + new GradientStop(Colors.Green, 2.0 / 6), + new GradientStop(Colors.Cyan, 3.0 / 6), + new GradientStop(Colors.Blue, 4.0 / 6), + new GradientStop(Colors.Magenta, 5.0 / 6), + new GradientStop(Colors.Red, 1.0) + ], + StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative), + }.ToImmutable(); + + s_luminanceGradientBrush = new LinearGradientBrush + { + GradientStops = + [ + new GradientStop(Colors.Black, 0.0), + new GradientStop(Colors.White, 1.0) + ], + StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative), + }.ToImmutable(); + + s_masterBackBrush = new LinearGradientBrush + { + GradientStops = + [ + new GradientStop(Color.FromArgb(40, 128, 128, 128), 0), + new GradientStop(Color.FromArgb(20, 128, 128, 128), 1) + ], + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }.ToImmutable(); + + s_redBackBrush = new LinearGradientBrush + { + GradientStops = + [ + new GradientStop(Color.FromArgb(40, 139, 0, 0), 0), + new GradientStop(Color.FromArgb(20, 139, 0, 0), 1) + ], + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }.ToImmutable(); + + s_greenBackBrush = new LinearGradientBrush + { + GradientStops = + [ + new GradientStop(Color.FromArgb(40, 0, 100, 0), 0), + new GradientStop(Color.FromArgb(20, 0, 100, 0), 1) + ], + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }.ToImmutable(); + + s_blueBackBrush = new LinearGradientBrush + { + GradientStops = + [ + new GradientStop(Color.FromArgb(40, 0, 0, 139), 0), + new GradientStop(Color.FromArgb(20, 0, 0, 139), 1) + ], + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }.ToImmutable(); + } + + public event EventHandler? Updated; + + public void UpdateHistogram(WriteableBitmap? sourceBitmap, HistogramCategory categories = HistogramCategory.All) + { + if (categories == HistogramCategory.None) + return; + + bool needRgb = categories.HasFlag(HistogramCategory.Rgb); + bool needHue = categories.HasFlag(HistogramCategory.Hue); + bool needLuma = categories.HasFlag(HistogramCategory.Luminance); + bool needSat = categories.HasFlag(HistogramCategory.Saturation); + bool needHsl = needHue || needLuma || needSat; + + if (needRgb) + { + Array.Clear(_rHist); + Array.Clear(_gHist); + Array.Clear(_bHist); + Array.Clear(_combinedHist); + _histMax = 0; + } + + if (needHue) + { + Array.Clear(_hueHist); + _hueHistMax = 0; + } + + if (needLuma) + { + Array.Clear(_lumaHist); + _lumaHistMax = 0; + } + + if (needSat) + { + Array.Clear(_satHist); + _satHistMax = 0; + } + + if (sourceBitmap is not { } bitmap) + return; + + using ILockedFramebuffer frame = bitmap.Lock(); + unsafe + { + var span = new ReadOnlySpan((void*)frame.Address, frame.RowBytes * frame.Size.Height); + int stepX = Math.Max(1, frame.Size.Width / 256); + int stepY = Math.Max(1, frame.Size.Height / 256); + + for (int y = 0; y < frame.Size.Height; y += stepY) + { + int row = y * frame.RowBytes; + for (int x = 0; x < frame.Size.Width; x += stepX) + { + int idx = row + (x * 4); + byte b = span[idx]; + byte g = span[idx + 1]; + byte r = span[idx + 2]; + + if (needRgb) + { + _bHist[b]++; + _gHist[g]++; + _rHist[r]++; + + int luminance = (int)Math.Round(r * 0.2126 + g * 0.7152 + b * 0.0722, MidpointRounding.AwayFromZero); + _combinedHist[luminance]++; + } + + if (needHsl) + { + RgbToHsl(r, g, b, out int hue, out int sat, out int luma); + + if (needHue && sat > 10) + { + _hueHist[hue]++; + } + + if (needLuma) + { + _lumaHist[luma]++; + } + + if (needSat) + { + _satHist[sat]++; + } + } + } + } + + if (needRgb) + { + _histMax = Math.Max(1, + Math.Max(_combinedHist.Max(), Math.Max(_rHist.Max(), Math.Max(_gHist.Max(), _bHist.Max())))); + } + + if (needHue) + { + _hueHistMax = Math.Max(1, _hueHist.Max()); + } + + if (needLuma) + { + _lumaHistMax = Math.Max(1, _lumaHist.Max()); + } + + if (needSat) + { + _satHistMax = Math.Max(1, _satHist.Max()); + } + } + + Updated?.Invoke(this, EventArgs.Empty); + } + + private static void RgbToHsl(byte r, byte g, byte b, out int hue, out int saturation, out int luminance) + { + float rf = r / 255f; + float gf = g / 255f; + float bf = b / 255f; + + float max = Math.Max(rf, Math.Max(gf, bf)); + float min = Math.Min(rf, Math.Min(gf, bf)); + float delta = max - min; + + // Luminance + float l = (max + min) / 2f; + luminance = (int)Math.Round(l * 255); + + // Saturation + float s = 0; + if (delta > 0.0001f) + { + s = delta / (1f - Math.Abs(2f * l - 1f)); + } + saturation = (int)Math.Clamp(Math.Round(s * 255), 0, 255); + + // Hue + float h = 0; + if (delta > 0.0001f) + { + if (max == rf) + { + h = 60f * (((gf - bf) / delta) % 6f); + } + else if (max == gf) + { + h = 60f * (((bf - rf) / delta) + 2f); + } + else + { + h = 60f * (((rf - gf) / delta) + 4f); + } + } + if (h < 0) h += 360f; + hue = (int)Math.Clamp(Math.Round(h), 0, 359); + } + + public void Draw(DrawingContext context, Rect bounds, CurveVisualization visualization) + { + switch (visualization) + { + case CurveVisualization.None: + break; + + case CurveVisualization.Master: + DrawHistogram(context, bounds, s_masterBackBrush, s_masterBarBrush, _combinedHist); + break; + + case CurveVisualization.Red: + DrawHistogram(context, bounds, s_redBackBrush, s_redBarBrush, _rHist); + break; + + case CurveVisualization.Green: + DrawHistogram(context, bounds, s_greenBackBrush, s_greenBarBrush, _gHist); + break; + + case CurveVisualization.Blue: + DrawHistogram(context, bounds, s_blueBackBrush, s_blueBarBrush, _bHist); + break; + + case CurveVisualization.HueVsHue: + case CurveVisualization.HueVsSaturation: + case CurveVisualization.HueVsLuminance: + DrawHueGradient(context, bounds); + break; + + case CurveVisualization.LuminanceVsSaturation: + DrawLuminanceGradient(context, bounds); + break; + + case CurveVisualization.SaturationVsSaturation: + DrawSaturationGradient(context, bounds); + break; + } + } + + private void DrawHistogram(DrawingContext context, Rect bounds, IBrush backBrush, IBrush barBrush, int[] histogram) + { + context.DrawRectangle(backBrush, null, bounds); + if (histogram.Length == 0 || _histMax <= 0) + return; + + double barWidth = bounds.Width / histogram.Length; + + for (int i = 0; i < histogram.Length; i++) + { + double height = bounds.Height * histogram[i] / _histMax; + var rect = new Rect(bounds.X + i * barWidth, bounds.Bottom - height, Math.Max(1, barWidth - 0.5), height); + context.DrawRectangle(barBrush, null, rect); + } + } + + private void DrawHueGradient(DrawingContext context, Rect bounds) + { + context.DrawRectangle(s_hueGradientBrush, null, bounds); + context.DrawRectangle(s_blackOverlayBrush, null, bounds); + + if (_hueHistMax > 0) + { + double barWidth = bounds.Width / 360.0; + + for (int i = 0; i < 360; i++) + { + double height = bounds.Height * _hueHist[i] / _hueHistMax; + var rect = new Rect(bounds.X + i * barWidth, bounds.Bottom - height, Math.Max(1, barWidth), height); + context.DrawRectangle(s_whiteBarBrush, null, rect); + } + } + } + + private void DrawLuminanceGradient(DrawingContext context, Rect bounds) + { + context.DrawRectangle(s_luminanceGradientBrush, null, bounds); + context.DrawRectangle(s_blackOverlayBrush, null, bounds); + + if (_lumaHistMax > 0) + { + double barWidth = bounds.Width / 256.0; + + for (int i = 0; i < 256; i++) + { + double height = bounds.Height * _lumaHist[i] / _lumaHistMax; + var rect = new Rect(bounds.X + i * barWidth, bounds.Bottom - height, Math.Max(1, barWidth), height); + context.DrawRectangle(s_whiteBarBrush, null, rect); + } + } + } + + private void DrawSaturationGradient(DrawingContext context, Rect bounds) + { + context.DrawRectangle(s_luminanceGradientBrush, null, bounds); + context.DrawRectangle(s_blackOverlayBrush, null, bounds); + + if (_satHistMax > 0) + { + double barWidth = bounds.Width / 256.0; + + for (int i = 0; i < 256; i++) + { + double height = bounds.Height * _satHist[i] / _satHistMax; + var rect = new Rect(bounds.X + i * barWidth, bounds.Bottom - height, Math.Max(1, barWidth), height); + context.DrawRectangle(s_whiteBarBrush, null, rect); + } + } + } +} diff --git a/src/Beutl.Controls/GradingColorHelper.cs b/src/Beutl.Controls/GradingColorHelper.cs new file mode 100644 index 000000000..c3b97b154 --- /dev/null +++ b/src/Beutl.Controls/GradingColorHelper.cs @@ -0,0 +1,115 @@ +using Beutl.Media; +using UnboundedHsv = (float H, float S, float V); + +namespace Beutl.Controls; + +public static class GradingColorHelper +{ + private const float EPSILON = 0.001f; + + public static UnboundedHsv GetUnboundedHsv(GradingColor color) + { + // HSVに変換する + var r = MathF.Abs(color.R); + var g = MathF.Abs(color.G); + var b = MathF.Abs(color.B); + var min = MathF.Min(r, MathF.Min(g, b)); + var max = MathF.Max(r, MathF.Max(g, b)); + var delta = max - min; + + var h = 0f; + var s = 0f; + var v = max; + if (color.R < 0 || color.G < 0 || color.B < 0) + { + v = -v; + } + + if (delta > EPSILON) + { + s = delta / max; + + if (MathF.Abs(r - max) < EPSILON) + { + h = ((g - b) / delta); + } + else if (MathF.Abs(g - max) < EPSILON) + { + h = (2f + (b - r) / delta); + } + else + { + h = (4f + (r - g) / delta); + } + + h *= 60; + } + + if (h < 0) + h += 360; + else if (h >= 360) + h -= 360; + + return (h, s, v); + } + + public static GradingColor GetColor(UnboundedHsv color) + { + var hue = color.H; + var sat = color.S; + var val = color.V; + float r, g, b; + r = g = b = val; + + if (hue >= 0 && sat >= EPSILON) + { + hue = (hue / 360f) * 6f; + + var h = (int)hue; + var v1 = val * (1f - sat); + var v2 = val * (1f - sat * (hue - h)); + var v3 = val * (1f - sat * (1f - (hue - h))); + + switch (h) + { + case 0: + r = val; + g = v3; + b = v1; + break; + + case 1: + r = v2; + g = val; + b = v1; + break; + + case 2: + r = v1; + g = val; + b = v3; + break; + + case 3: + r = v1; + g = v2; + b = val; + break; + + case 4: + r = v3; + g = v1; + b = val; + break; + + case 5: + r = val; + g = v1; + b = v2; + break; + } + } + + return new GradingColor(r, g, b); + } +} diff --git a/src/Beutl.Controls/PropertyEditors/ColorGradingWheel.cs b/src/Beutl.Controls/PropertyEditors/ColorGradingWheel.cs new file mode 100644 index 000000000..a4d907ae1 --- /dev/null +++ b/src/Beutl.Controls/PropertyEditors/ColorGradingWheel.cs @@ -0,0 +1,76 @@ +#nullable enable + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Beutl.Media; + +namespace Beutl.Controls.PropertyEditors; + +public class ColorGradingWheel : PropertyEditor +{ + public static readonly DirectProperty ColorProperty = + AvaloniaProperty.RegisterDirect( + nameof(Color), + o => o.Color, + (o, v) => o.Color = v, + defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty InputTypeProperty = + AvaloniaProperty.Register(nameof(InputType)); + + public static readonly StyledProperty ShowDetailsProperty = + AvaloniaProperty.Register(nameof(ShowDetails)); + + private GradingColorPicker? _gradingColorPicker; + + public GradingColor Color + { + get; + set => SetAndRaise(ColorProperty, ref field, value); + } + + public GradingColorPickerInputType InputType + { + get => GetValue(InputTypeProperty); + set => SetValue(InputTypeProperty, value); + } + + public bool ShowDetails + { + get => GetValue(ShowDetailsProperty); + set => SetValue(ShowDetailsProperty, value); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + if (_gradingColorPicker != null) + { + _gradingColorPicker.ColorChanged -= OnColorChanged; + _gradingColorPicker.ColorConfirmed -= OnColorConfirmed; + } + + _gradingColorPicker = e.NameScope.Find("PART_GradingColorPicker"); + + if (_gradingColorPicker != null) + { + _gradingColorPicker.ColorChanged += OnColorChanged; + _gradingColorPicker.ColorConfirmed += OnColorConfirmed; + } + } + + private void OnColorConfirmed(GradingColorPicker sender, (GradingColor OldValue, GradingColor NewValue) args) + { + RaiseEvent(new PropertyEditorValueChangedEventArgs( + args.NewValue, args.OldValue, ValueConfirmedEvent)); + } + + private void OnColorChanged(GradingColorPicker sender, (GradingColor OldValue, GradingColor NewValue) args) + { + RaiseEvent(new PropertyEditorValueChangedEventArgs( + args.NewValue, args.OldValue, ValueChangedEvent)); + } +} diff --git a/src/Beutl.Controls/PropertyEditors/GradingColorBrushConverter.cs b/src/Beutl.Controls/PropertyEditors/GradingColorBrushConverter.cs new file mode 100644 index 000000000..0b824fba4 --- /dev/null +++ b/src/Beutl.Controls/PropertyEditors/GradingColorBrushConverter.cs @@ -0,0 +1,33 @@ +#nullable enable + +using System.Globalization; + +using Avalonia.Data.Converters; +using Avalonia.Media; + +using Beutl.Media; + +using ASolidColorBrush = Avalonia.Media.SolidColorBrush; +using AColor = Avalonia.Media.Color; + +namespace Beutl.Controls.PropertyEditors; + +public class GradingColorBrushConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is GradingColor gradingColor) + { + var beutlColor = gradingColor.ToColor(); + var avaloniaColor = AColor.FromArgb(beutlColor.A, beutlColor.R, beutlColor.G, beutlColor.B); + return new ASolidColorBrush(avaloniaColor); + } + + return null; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/src/Beutl.Controls/PropertyEditors/GradingColorComponentsEditor.cs b/src/Beutl.Controls/PropertyEditors/GradingColorComponentsEditor.cs new file mode 100644 index 000000000..74c05d15b --- /dev/null +++ b/src/Beutl.Controls/PropertyEditors/GradingColorComponentsEditor.cs @@ -0,0 +1,145 @@ +using Avalonia; +using Beutl.Language; +using Beutl.Media; +using FluentAvalonia.UI.Media; +using UnboundedHsv = (float H, float S, float V); + +#nullable enable + +namespace Beutl.Controls.PropertyEditors; + +public class GradingColorComponentsEditor : Vector3Editor +{ + public static readonly DirectProperty ColorProperty = + AvaloniaProperty.RegisterDirect(nameof(Color), + x => x.Color, (x, v) => x.Color = v); + + public static readonly DirectProperty RgbProperty = + AvaloniaProperty.RegisterDirect(nameof(Rgb), + x => x.Rgb, (x, v) => x.Rgb = v, unsetValue: true); + + private GradingColor _color = new GradingColor(1, 1, 1); + private bool _rgb = true; + + static GradingColorComponentsEditor() + { + ValueChangedEvent.AddClassHandler((t, args) => + { + if (args is PropertyEditorValueChangedEventArgs<(float, float, float)> e) + { + t.Color = t.ToGradingColorFromTuple(e.NewValue); + t.UpdateProperties(); + } + }); + } + + public GradingColorComponentsEditor() + { + UpdateHeaders(); + UpdateProperties(); + } + + public GradingColor Color + { + get => _color; + set + { + if (SetAndRaise(ColorProperty, ref _color, value)) + { + UpdateProperties(); + } + } + } + + public bool Rgb + { + get => _rgb; + set + { + if (SetAndRaise(RgbProperty, ref _rgb, value)) + { + UpdateHeaders(); + UpdateProperties(); + } + } + } + + private void UpdateHeaders() + { + if (Rgb) + { + FirstHeader = Strings.Red; + SecondHeader = Strings.Green; + ThirdHeader = Strings.Blue; + } + else + { + FirstHeader = Strings.Hue; + SecondHeader = Strings.Saturation; + ThirdHeader = Strings.Brightness; + } + } + + private void UpdateProperties() + { + if (Rgb) + { + FirstValue = Color.R * 100f; + SecondValue = Color.G * 100f; + ThirdValue = Color.B * 100f; + } + else + { + var color2 = GradingColorHelper.GetUnboundedHsv(Color); + FirstValue = color2.H; + SecondValue = color2.S * 100f; + ThirdValue = color2.V * 100f; + } + } + + public GradingColor ToGradingColorFromTuple((float, float, float) t) + { + if (Rgb) + { + return new GradingColor( + t.Item1 / 100f, + t.Item2 / 100f, + t.Item3 / 100f); + } + else + { + (float h, float s, float v) = t; + h %= 360; + if (h < 0) + { + h += 360; + } + + var hsv = (h, s / 100f, v / 100f); + return GradingColorHelper.GetColor(hsv); + } + } + + public (GradingColor?, UnboundedHsv?) GetGradingColorOrUnboundedHsv((float, float, float) t) + { + if (Rgb) + { + return (new GradingColor( + t.Item1 / 100f, + t.Item2 / 100f, + t.Item3 / 100f), + null); + } + else + { + (float h, float s, float v) = t; + h %= 360; + if (h < 0) + { + h += 360; + } + + return (null, (h, s / 100f, v / 100f)); + } + } +} diff --git a/src/Beutl.Controls/PropertyEditors/GradingColorEditor.cs b/src/Beutl.Controls/PropertyEditors/GradingColorEditor.cs new file mode 100644 index 000000000..98cf35992 --- /dev/null +++ b/src/Beutl.Controls/PropertyEditors/GradingColorEditor.cs @@ -0,0 +1,143 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Interactivity; + +using Beutl.Media; + +#nullable enable + +namespace Beutl.Controls.PropertyEditors; + +public class GradingColorEditor : PropertyEditor +{ + public static readonly DirectProperty ValueProperty = + AvaloniaProperty.RegisterDirect( + nameof(Value), + o => o.Value, + (o, v) => o.Value = v, + defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty IsLivePreviewEnabledProperty = + AvaloniaProperty.Register(nameof(IsLivePreviewEnabled)); + + private GradingColorPickerFlyout? _flyout; + + private bool _flyoutActive; + private Button? _button; + + private GradingColor _oldValue; + private GradingColor _value; + + public GradingColor Value + { + get => _value; + set => SetAndRaise(ValueProperty, ref _value, value); + } + + public bool IsLivePreviewEnabled + { + get => GetValue(IsLivePreviewEnabledProperty); + set => SetValue(IsLivePreviewEnabledProperty, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == ValueProperty) + { + _flyout?.Color = Value; + } + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + if (_button != null) + { + _button.Click -= OnButtonClick; + } + base.OnApplyTemplate(e); + _button = e.NameScope.Get + + + + + + + diff --git a/src/Beutl/Views/Editors/CurveMapEditor.axaml.cs b/src/Beutl/Views/Editors/CurveMapEditor.axaml.cs new file mode 100644 index 000000000..894e02736 --- /dev/null +++ b/src/Beutl/Views/Editors/CurveMapEditor.axaml.cs @@ -0,0 +1,36 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Beutl.ViewModels; +using Beutl.ViewModels.Editors; +using Beutl.ViewModels.Tools; +using Microsoft.Extensions.DependencyInjection; + +namespace Beutl.Views.Editors; + +public partial class CurveMapEditor : UserControl +{ + public CurveMapEditor() + { + InitializeComponent(); + } + + private void OpenCurvesTab_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is CurveMapEditorViewModel { IsDisposed: false } viewModel + && viewModel.GetService() is { } editViewModel + && viewModel.TryGetCurves() is { } curves) + { + CurvesTabViewModel context = editViewModel.FindToolTab() + ?? new CurvesTabViewModel(editViewModel); + + context.Effect.Value = curves; + var prop = viewModel.PropertyAdapter.GetEngineProperty(); + if (prop != null) + { + context.SelectCurveByPropertyName(prop.Name); + } + + editViewModel.OpenToolTab(context); + } + } +} diff --git a/src/Beutl/Views/Tools/ColorGradingTab.axaml b/src/Beutl/Views/Tools/ColorGradingTab.axaml new file mode 100644 index 000000000..2500b550b --- /dev/null +++ b/src/Beutl/Views/Tools/ColorGradingTab.axaml @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + + + + +