Sei moduli avanzati per la produzione cinematografica
Estende il motore con stabilizzazione sub-pixel, deflicker per regioni, transizioni giorno-notte, movimento di macchina virtuale, rimappatura non lineare del tempo e accumulo temporale. Tutto in-house, nessuna dipendenza aggiunta: il progetto continua a non contenere un solo PackageReference. Perche' questa forma. Il rendering non percorre piu' la sequenza sorgente ma un piano di fotogrammi d'uscita, ognuno con posizione anche frazionaria e durata propria. Le quattro modalita' temporali producono tutte quella stessa forma, quindi il ciclo di rendering e' uno solo e non ha un ramo per ciascun caso; da li' discende anche la sfocatura, perche' un fotogramma che copre v scatti ha angolo di otturatore diviso v. Gli spostamenti si misurano con la correlazione di fase, che ignora per costruzione le differenze di luminosita' fra scatti - in un time-lapse ci sono sempre - e reagisce alla sola geometria. Il picco intero non basta: la superficie di correlazione viene ricostruita a passo fine valutando la somma di Fourier sulle posizioni intermedie invece di interpolare con una parabola tre campioni di una cresta che parabola non e'. L'errore misurato scende da 0,14 a 0,08 px. I gradini di esposizione non sono rumore da mediare: l'ampiezza si legge esatta nei metadati e viene ridistribuita su una transizione a derivata nulla agli estremi. Il deflicker lavora poi su una serie gia' priva di gradini, invece di trasformare lo scalino in una rampa con due spigoli. La maschera delle regioni nasce dalla mediana temporale di un campione di fotogrammi, che toglie di mezzo proprio le nuvole di passaggio, e la linea d'orizzonte viene agganciata al massimo del gradiente verticale. Sulla scena di prova il terreno passa da 0,062 a 0,026 stop di oscillazione. Ritaglio virtuale e correzione di stabilizzazione sono entrambi affini e vengono composti in una sola trasformazione: due ricampionamenti in fila costerebbero il doppio di nitidezza senza dare nulla in cambio. Sulla memoria: i moduli avanzati hanno rotto l'assunto che bastassero due fotogrammi vivi alla volta, quindi il disco entra ora in gioco - come annotato nel commit precedente, e' questa la porta che si apriva. La finestra attiva resta sempre in memoria perche' la mediana ha bisogno di tutti i suoi fotogrammi insieme; solo la lettura in anticipo viene parcheggiata su disco oltre il tetto, e ripresa una volta sola. Non esiste un caso in cui lo stesso fotogramma vada e torni piu' volte. Il file di parcheggio si cancella da se'. Verifica: da 26 a 51 controlli. Nessuna soglia scelta a posteriori - il tremolio ha un percorso noto, il gradino un'ampiezza dichiarata nei metadati e visibile nei pixel, la nuvola attraversa il solo cielo. Il controllo conclusivo rende una sequenza con tutti i moduli attivi insieme e tetto di memoria volutamente stretto, poi la rilegge con il lettore di sistema. Verificato anche sui DNG GoPro reali: 580 file, render di prova conforme. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
using Titano.Motion;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Editor del movimento di macchina virtuale: mostra il fotogramma sorgente per intero e,
|
||||
/// dentro di esso, le inquadrature dei nodi con il percorso che il centro descrive fra l'uno
|
||||
/// e l'altro.
|
||||
///
|
||||
/// Il percorso disegnato non è una retta fra i nodi ma la curva davvero eseguita, campionata
|
||||
/// con la stessa funzione di accelerazione che userà il rendering: è l'unico modo perché
|
||||
/// l'anteprima dica qualcosa di vero su come si muoverà l'inquadratura, e perché l'effetto di
|
||||
/// una maniglia di accelerazione si veda mentre la si regola.
|
||||
///
|
||||
/// Le inquadrature si spostano trascinandole e si stringono trascinando la maniglia
|
||||
/// nell'angolo. La striscia in basso è la linea del tempo: ogni nodo è un indicatore che si
|
||||
/// seleziona con un clic e si sposta trascinandolo.
|
||||
/// </summary>
|
||||
internal sealed class CameraEditor : Control
|
||||
{
|
||||
private const int TimelineHeight = 26;
|
||||
private const float HandleSize = 9f;
|
||||
|
||||
private VirtualCameraSettings _settings = new();
|
||||
private int _selected;
|
||||
private int _dragging = -1;
|
||||
private bool _draggingZoom;
|
||||
private bool _draggingTime;
|
||||
private PointF _grabOffset;
|
||||
|
||||
public event EventHandler? Changed;
|
||||
public event EventHandler? SelectionChanged;
|
||||
|
||||
/// <summary>Rapporto larghezza/altezza del fotogramma sorgente.</summary>
|
||||
public double Aspect { get; set; } = 16.0 / 9.0;
|
||||
|
||||
public CameraEditor()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Surface;
|
||||
Height = 220;
|
||||
}
|
||||
|
||||
public VirtualCameraSettings Settings
|
||||
{
|
||||
get => _settings;
|
||||
set
|
||||
{
|
||||
_settings = value;
|
||||
_selected = Math.Clamp(_selected, 0, Math.Max(0, value.Keyframes.Count - 1));
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => Math.Clamp(_selected, 0, Math.Max(0, _settings.Keyframes.Count - 1));
|
||||
set
|
||||
{
|
||||
int clamped = Math.Clamp(value, 0, Math.Max(0, _settings.Keyframes.Count - 1));
|
||||
if (clamped == _selected) return;
|
||||
_selected = clamped;
|
||||
Invalidate();
|
||||
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public CameraKeyframe? Selected => _settings.Keyframes.Count > 0
|
||||
? _settings.Keyframes[SelectedIndex]
|
||||
: null;
|
||||
|
||||
/// <summary>Inserisce un nodo a metà fra quello scelto e il successivo.</summary>
|
||||
public void AddKeyframe()
|
||||
{
|
||||
var keyframes = _settings.Keyframes;
|
||||
int index = SelectedIndex;
|
||||
|
||||
double time = index < keyframes.Count - 1
|
||||
? (keyframes[index].Time + keyframes[index + 1].Time) * 0.5
|
||||
: Math.Min(1, keyframes[^1].Time + 0.1);
|
||||
|
||||
var framing = VirtualCamera.Resolve(new VirtualCameraSettings
|
||||
{
|
||||
Enabled = true,
|
||||
Keyframes = keyframes,
|
||||
}, time);
|
||||
|
||||
keyframes.Add(new CameraKeyframe
|
||||
{
|
||||
Time = time,
|
||||
CentreX = framing.CentreX,
|
||||
CentreY = framing.CentreY,
|
||||
Zoom = framing.Zoom,
|
||||
});
|
||||
keyframes.Sort((a, b) => a.Time.CompareTo(b.Time));
|
||||
|
||||
_selected = keyframes.FindIndex(k => Math.Abs(k.Time - time) < 1e-9);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>Toglie il nodo scelto; due devono restare, o non ci sarebbe più un movimento.</summary>
|
||||
public void RemoveSelected()
|
||||
{
|
||||
if (_settings.Keyframes.Count <= 2) return;
|
||||
_settings.Keyframes.RemoveAt(SelectedIndex);
|
||||
_selected = Math.Clamp(_selected, 0, _settings.Keyframes.Count - 1);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ geometria
|
||||
|
||||
private Rectangle Timeline => new(10, Height - TimelineHeight, Math.Max(20, Width - 20), TimelineHeight - 8);
|
||||
|
||||
/// <summary>Rettangolo che rappresenta il fotogramma sorgente, con le sue proporzioni.</summary>
|
||||
private RectangleF Stage
|
||||
{
|
||||
get
|
||||
{
|
||||
var available = new RectangleF(10, 8, Math.Max(20, Width - 20),
|
||||
Math.Max(20, Height - TimelineHeight - 16));
|
||||
float aspect = (float)Math.Max(0.1, Aspect);
|
||||
float width = available.Width;
|
||||
float height = width / aspect;
|
||||
|
||||
if (height > available.Height)
|
||||
{
|
||||
height = available.Height;
|
||||
width = height * aspect;
|
||||
}
|
||||
|
||||
return new RectangleF(available.Left + (available.Width - width) / 2,
|
||||
available.Top + (available.Height - height) / 2, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
private RectangleF FramingRect(CameraKeyframe keyframe)
|
||||
{
|
||||
var stage = Stage;
|
||||
var framing = VirtualCamera.Constrain(new CameraFraming(keyframe.CentreX, keyframe.CentreY, keyframe.Zoom),
|
||||
_settings.KeepInsideFrame);
|
||||
float width = (float)(stage.Width / framing.Zoom);
|
||||
float height = (float)(stage.Height / framing.Zoom);
|
||||
return new RectangleF(stage.Left + (float)(framing.CentreX * stage.Width) - width / 2,
|
||||
stage.Top + (float)(framing.CentreY * stage.Height) - height / 2,
|
||||
width, height);
|
||||
}
|
||||
|
||||
private PointF TimelinePoint(CameraKeyframe keyframe)
|
||||
{
|
||||
var timeline = Timeline;
|
||||
return new PointF(timeline.Left + (float)(keyframe.Time * timeline.Width),
|
||||
timeline.Top + timeline.Height / 2f);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ interazione
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
Focus();
|
||||
if (e.Button != MouseButtons.Left) { base.OnMouseDown(e); return; }
|
||||
|
||||
var keyframes = _settings.Keyframes;
|
||||
|
||||
// La linea del tempo ha la precedenza: è la striscia più sottile e va raggiunta prima.
|
||||
if (e.Y >= Timeline.Top - 6)
|
||||
{
|
||||
for (int i = 0; i < keyframes.Count; i++)
|
||||
{
|
||||
var point = TimelinePoint(keyframes[i]);
|
||||
if (Math.Abs(point.X - e.X) > 8) continue;
|
||||
SelectedIndex = i;
|
||||
_dragging = i;
|
||||
_draggingTime = true;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Maniglia dell'ingrandimento sull'inquadratura scelta.
|
||||
if (Selected is { } current)
|
||||
{
|
||||
var rect = FramingRect(current);
|
||||
var handle = new RectangleF(rect.Right - HandleSize, rect.Bottom - HandleSize,
|
||||
HandleSize * 2, HandleSize * 2);
|
||||
if (handle.Contains(e.Location))
|
||||
{
|
||||
_dragging = SelectedIndex;
|
||||
_draggingZoom = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Altrimenti si sceglie l'inquadratura più stretta che contiene il punto: quelle
|
||||
// piccole stanno dentro le grandi, e cliccando sulla piccola si vuole la piccola.
|
||||
int best = -1;
|
||||
double bestArea = double.MaxValue;
|
||||
for (int i = 0; i < keyframes.Count; i++)
|
||||
{
|
||||
var rect = FramingRect(keyframes[i]);
|
||||
if (!rect.Contains(e.Location)) continue;
|
||||
double area = rect.Width * (double)rect.Height;
|
||||
if (area >= bestArea) continue;
|
||||
bestArea = area;
|
||||
best = i;
|
||||
}
|
||||
|
||||
if (best < 0) return;
|
||||
|
||||
SelectedIndex = best;
|
||||
_dragging = best;
|
||||
_draggingZoom = false;
|
||||
_draggingTime = false;
|
||||
|
||||
var selectedRect = FramingRect(keyframes[best]);
|
||||
_grabOffset = new PointF(e.X - (selectedRect.Left + selectedRect.Width / 2),
|
||||
e.Y - (selectedRect.Top + selectedRect.Height / 2));
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
if (_dragging < 0 || _dragging >= _settings.Keyframes.Count)
|
||||
{
|
||||
Cursor = CursorFor(e.Location);
|
||||
base.OnMouseMove(e);
|
||||
return;
|
||||
}
|
||||
|
||||
var keyframe = _settings.Keyframes[_dragging];
|
||||
var stage = Stage;
|
||||
|
||||
if (_draggingTime)
|
||||
{
|
||||
var timeline = Timeline;
|
||||
double time = Math.Clamp((e.X - timeline.Left) / (double)timeline.Width, 0, 1);
|
||||
|
||||
// Gli estremi restano agli estremi: il movimento deve coprire tutta la sequenza.
|
||||
if (_dragging > 0 && _dragging < _settings.Keyframes.Count - 1)
|
||||
{
|
||||
keyframe.Time = Math.Clamp(time,
|
||||
_settings.Keyframes[_dragging - 1].Time + 0.01,
|
||||
_settings.Keyframes[_dragging + 1].Time - 0.01);
|
||||
}
|
||||
}
|
||||
else if (_draggingZoom)
|
||||
{
|
||||
// L'ingrandimento segue la semilarghezza trascinata: il rettangolo insegue il dito.
|
||||
float halfWidth = Math.Max(4f, e.X - (stage.Left + (float)(keyframe.CentreX * stage.Width)));
|
||||
double zoom = stage.Width / (2.0 * halfWidth);
|
||||
keyframe.Zoom = Math.Clamp(zoom, 1.0, 8.0);
|
||||
}
|
||||
else
|
||||
{
|
||||
keyframe.CentreX = Math.Clamp((e.X - _grabOffset.X - stage.Left) / stage.Width, 0, 1);
|
||||
keyframe.CentreY = Math.Clamp((e.Y - _grabOffset.Y - stage.Top) / stage.Height, 0, 1);
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
if (_dragging >= 0) _settings.Keyframes.Sort((a, b) => a.Time.CompareTo(b.Time));
|
||||
_dragging = -1;
|
||||
_draggingZoom = false;
|
||||
_draggingTime = false;
|
||||
Invalidate();
|
||||
base.OnMouseUp(e);
|
||||
}
|
||||
|
||||
private Cursor CursorFor(Point location)
|
||||
{
|
||||
if (location.Y >= Timeline.Top - 6) return Cursors.SizeWE;
|
||||
if (Selected is not { } current) return Cursors.Default;
|
||||
|
||||
var rect = FramingRect(current);
|
||||
var handle = new RectangleF(rect.Right - HandleSize, rect.Bottom - HandleSize,
|
||||
HandleSize * 2, HandleSize * 2);
|
||||
if (handle.Contains(location)) return Cursors.SizeNWSE;
|
||||
return rect.Contains(location) ? Cursors.SizeAll : Cursors.Default;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ disegno
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Parent?.BackColor ?? Theme.Surface);
|
||||
|
||||
var stage = Stage;
|
||||
Theme.FillRounded(g, stage, 4f, Theme.SurfaceAlt);
|
||||
using (var border = new Pen(Theme.Border)) g.DrawRectangle(border, Rectangle.Round(stage));
|
||||
|
||||
TextRenderer.DrawText(g, "risoluzione nativa", Theme.Small,
|
||||
new Rectangle((int)stage.Left + 6, (int)stage.Top + 4, 160, 14),
|
||||
Theme.TextFaint, TextFormatFlags.Left);
|
||||
|
||||
var keyframes = _settings.Keyframes;
|
||||
if (keyframes.Count == 0) return;
|
||||
|
||||
// Percorso del centro, campionato sulla curva vera: comprende l'accelerazione.
|
||||
var samples = new PointF[128];
|
||||
for (int i = 0; i < samples.Length; i++)
|
||||
{
|
||||
double t = i / (double)(samples.Length - 1);
|
||||
var framing = VirtualCamera.Constrain(VirtualCamera.Resolve(
|
||||
new VirtualCameraSettings { Enabled = true, Keyframes = keyframes }, t),
|
||||
_settings.KeepInsideFrame);
|
||||
samples[i] = new PointF(stage.Left + (float)(framing.CentreX * stage.Width),
|
||||
stage.Top + (float)(framing.CentreY * stage.Height));
|
||||
}
|
||||
|
||||
using (var pathPen = new Pen(Color.FromArgb(150, Theme.Success), 1.6f) { DashStyle = DashStyle.Dash })
|
||||
{
|
||||
g.DrawLines(pathPen, samples);
|
||||
}
|
||||
|
||||
// Le tacche lungo il percorso sono equispaziate nel tempo: dove si addensano il
|
||||
// movimento sta rallentando, dove si diradano sta correndo. È la lettura immediata
|
||||
// dell'effetto delle maniglie di accelerazione.
|
||||
using (var tick = new SolidBrush(Color.FromArgb(190, Theme.Success)))
|
||||
{
|
||||
for (int i = 0; i < samples.Length; i += 8)
|
||||
g.FillEllipse(tick, samples[i].X - 1.6f, samples[i].Y - 1.6f, 3.2f, 3.2f);
|
||||
}
|
||||
|
||||
for (int i = 0; i < keyframes.Count; i++)
|
||||
{
|
||||
var rect = FramingRect(keyframes[i]);
|
||||
bool active = i == SelectedIndex;
|
||||
|
||||
using var pen = new Pen(active ? Theme.Accent : Color.FromArgb(120, Theme.Text), active ? 2f : 1.2f);
|
||||
if (!active) pen.DashStyle = DashStyle.Dash;
|
||||
g.DrawRectangle(pen, rect.Left, rect.Top, rect.Width, rect.Height);
|
||||
|
||||
if (!active) continue;
|
||||
|
||||
using (var fill = new SolidBrush(Color.FromArgb(26, Theme.Accent))) g.FillRectangle(fill, rect);
|
||||
using (var handle = new SolidBrush(Theme.Accent))
|
||||
{
|
||||
g.FillRectangle(handle, rect.Right - HandleSize / 2, rect.Bottom - HandleSize / 2,
|
||||
HandleSize, HandleSize);
|
||||
}
|
||||
|
||||
// In alto a destra: a ingrandimento 1 il riquadro coincide con il fotogramma e a
|
||||
// sinistra c'è già la didascalia della risoluzione nativa.
|
||||
string label = $"{keyframes[i].Zoom:0.00}×";
|
||||
TextRenderer.DrawText(g, label, Theme.SmallBold,
|
||||
new Rectangle((int)rect.Right - 74, (int)rect.Top + 3, 70, 15),
|
||||
Theme.Text, TextFormatFlags.Right);
|
||||
}
|
||||
|
||||
DrawTimeline(g, keyframes);
|
||||
}
|
||||
|
||||
private void DrawTimeline(Graphics g, List<CameraKeyframe> keyframes)
|
||||
{
|
||||
var timeline = Timeline;
|
||||
Theme.FillRounded(g, new RectangleF(timeline.Left, timeline.Top + timeline.Height / 2f - 2,
|
||||
timeline.Width, 4), 2f, Theme.SurfaceAlt);
|
||||
|
||||
for (int i = 0; i < keyframes.Count; i++)
|
||||
{
|
||||
var point = TimelinePoint(keyframes[i]);
|
||||
bool active = i == SelectedIndex;
|
||||
float radius = active ? 6f : 4.5f;
|
||||
|
||||
// Losanga, come nei programmi di montaggio: si distingue da un punto qualsiasi.
|
||||
var diamond = new PointF[]
|
||||
{
|
||||
new(point.X, point.Y - radius),
|
||||
new(point.X + radius, point.Y),
|
||||
new(point.X, point.Y + radius),
|
||||
new(point.X - radius, point.Y),
|
||||
};
|
||||
|
||||
using var fill = new SolidBrush(active ? Theme.Accent : Theme.TextMuted);
|
||||
using var stroke = new Pen(Theme.Background, 1.5f);
|
||||
g.FillPolygon(fill, diamond);
|
||||
g.DrawPolygon(stroke, diamond);
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
-14
@@ -214,6 +214,11 @@ internal sealed class LuminanceChart : Control
|
||||
{
|
||||
minValue = Math.Min(minValue, Math.Min(_curve.Measured[i], _curve.Target[i]));
|
||||
maxValue = Math.Max(maxValue, Math.Max(_curve.Measured[i], _curve.Target[i]));
|
||||
|
||||
// Le curve di regione stanno per definizione fuori da quella globale: senza
|
||||
// includerle nella scala uscirebbero dal riquadro.
|
||||
if (_curve.MeasuredHigh is { } high) maxValue = Math.Max(maxValue, high[i]);
|
||||
if (_curve.MeasuredLow is { } low) minValue = Math.Min(minValue, low[i]);
|
||||
}
|
||||
if (minValue > maxValue) { minValue = -4; maxValue = -1; }
|
||||
|
||||
@@ -223,7 +228,9 @@ internal sealed class LuminanceChart : Control
|
||||
|
||||
DrawGrid(g, plot, minValue, maxValue);
|
||||
DrawCadenceMarkers(g, plot);
|
||||
DrawExposureSteps(g, plot);
|
||||
DrawCorrectionBand(g, plot, from, to, minValue, maxValue);
|
||||
DrawRegionCurves(g, plot, from, to, minValue, maxValue);
|
||||
DrawCurve(g, plot, _curve.Measured, from, to, minValue, maxValue, Theme.Measured, 1.4f);
|
||||
DrawCurve(g, plot, _curve.Target, from, to, minValue, maxValue, Theme.Accent, 2.1f);
|
||||
DrawGainLane(g, from, to);
|
||||
@@ -314,6 +321,68 @@ internal sealed class LuminanceChart : Control
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Segnala i fotogrammi in cui la macchina ha cambiato tempo, diaframma o sensibilità.
|
||||
/// Sono i punti in cui la curva misurata fa un gradino e quella obiettivo no: vederli
|
||||
/// spiega a colpo d'occhio da dove viene la correzione più vistosa della sequenza.
|
||||
/// </summary>
|
||||
private void DrawExposureSteps(Graphics g, Rectangle plot)
|
||||
{
|
||||
if (_sequence is null) return;
|
||||
|
||||
int from = Math.Max(0, (int)_viewStart);
|
||||
int to = Math.Min(_sequence.Count - 1, (int)Math.Ceiling(_viewEnd));
|
||||
|
||||
using var pen = new Pen(Color.FromArgb(110, Theme.Success), 1f) { DashStyle = DashStyle.Dash };
|
||||
using var marker = new SolidBrush(Theme.Success);
|
||||
|
||||
for (int i = from; i <= to; i++)
|
||||
{
|
||||
if (!_sequence.Frames[i].IsExposureStep) continue;
|
||||
float x = XFor(i, plot);
|
||||
if (x < plot.Left || x > plot.Right) continue;
|
||||
|
||||
g.DrawLine(pen, x, plot.Top, x, plot.Bottom);
|
||||
g.FillPolygon(marker,
|
||||
[
|
||||
new PointF(x, plot.Top + 7),
|
||||
new PointF(x - 4.5f, plot.Top),
|
||||
new PointF(x + 4.5f, plot.Top),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Curve delle due regioni, quando il deflicker le distingue. Sono disegnate sottili e
|
||||
/// smorzate: raccontano perché la curva globale si muove come si muove, senza rubarle
|
||||
/// la scena.
|
||||
/// </summary>
|
||||
private void DrawRegionCurves(Graphics g, Rectangle plot, int from, int to, double min, double max)
|
||||
{
|
||||
if (_curve is not { HasRegions: true }) return;
|
||||
|
||||
var clip = g.Clip;
|
||||
g.SetClip(Rectangle.Inflate(plot, 2, 2));
|
||||
|
||||
DrawThin(_curve.MeasuredHigh, Color.FromArgb(110, Theme.RegionHigh));
|
||||
DrawThin(_curve.TargetHigh, Color.FromArgb(200, Theme.RegionHigh));
|
||||
DrawThin(_curve.MeasuredLow, Color.FromArgb(110, Theme.RegionLow));
|
||||
DrawThin(_curve.TargetLow, Color.FromArgb(200, Theme.RegionLow));
|
||||
|
||||
g.Clip = clip;
|
||||
|
||||
void DrawThin(double[]? values, Color color)
|
||||
{
|
||||
if (values is null || to - from < 1) return;
|
||||
var points = new PointF[to - from + 1];
|
||||
for (int i = 0; i < points.Length; i++)
|
||||
points[i] = new PointF(XFor(from + i, plot), YFor(values[from + i], plot, min, max));
|
||||
|
||||
using var pen = new Pen(color, 1.2f) { LineJoin = LineJoin.Round };
|
||||
g.DrawLines(pen, points);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Area fra misurato e target: è la correzione che verrà applicata.</summary>
|
||||
private void DrawCorrectionBand(Graphics g, Rectangle plot, int from, int to, double min, double max)
|
||||
{
|
||||
@@ -478,28 +547,46 @@ internal sealed class LuminanceChart : Control
|
||||
|
||||
private void DrawLegend(Graphics g)
|
||||
{
|
||||
var entries = new (Color Color, string Label)[]
|
||||
var entries = new List<(Color Color, string Label, bool Band)>
|
||||
{
|
||||
(Theme.Measured, "luminanza misurata"),
|
||||
(Theme.Accent, "curva target"),
|
||||
(Theme.Warning, "cadenza anomala"),
|
||||
(Theme.Measured, "luminanza misurata", false),
|
||||
(Theme.Accent, "curva target", false),
|
||||
};
|
||||
|
||||
int x = GutterLeft;
|
||||
for (int i = 0; i < entries.Length; i++)
|
||||
if (_curve is { HasRegions: true })
|
||||
{
|
||||
var (color, label) = entries[i];
|
||||
// L'ultima voce indica una fascia di sfondo, non una curva: si disegna come tale.
|
||||
bool band = i == entries.Length - 1;
|
||||
entries.Add((Theme.RegionHigh, "cielo", false));
|
||||
entries.Add((Theme.RegionLow, "paesaggio", false));
|
||||
}
|
||||
|
||||
if (_sequence is not null && HasExposureSteps()) entries.Add((Theme.Success, "cambio impostazioni", false));
|
||||
entries.Add((Theme.Warning, "cadenza anomala", true));
|
||||
|
||||
int x = GutterLeft;
|
||||
foreach (var (color, label, band) in entries)
|
||||
{
|
||||
var size = TextRenderer.MeasureText(g, label, Theme.Small);
|
||||
// Oltre il bordo non si disegna: meglio una legenda corta che una tagliata.
|
||||
if (x + 19 + size.Width > Width - 180) break;
|
||||
|
||||
using (var brush = new SolidBrush(band ? Color.FromArgb(90, color) : color))
|
||||
g.FillRectangle(brush, x, band ? 5 : 10, 14, band ? 12 : 3);
|
||||
var size = TextRenderer.MeasureText(g, label, Theme.Small);
|
||||
TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(x + 19, 4, size.Width + 4, 16),
|
||||
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
x += 19 + size.Width + 18;
|
||||
x += 19 + size.Width + 16;
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasExposureSteps()
|
||||
{
|
||||
if (_sequence is null) return false;
|
||||
foreach (var frame in _sequence.Frames)
|
||||
{
|
||||
if (frame.IsExposureStep) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void DrawHover(Graphics g, Rectangle plot, double min, double max)
|
||||
{
|
||||
if (_hoverIndex < 0 || _sequence is null || _hoverIndex >= _sequence.Count || _curve is null) return;
|
||||
@@ -516,14 +603,28 @@ internal sealed class LuminanceChart : Control
|
||||
using (var brush = new SolidBrush(Theme.Accent)) g.FillEllipse(brush, x - 3.5f, targetY - 3.5f, 7, 7);
|
||||
|
||||
var record = _sequence.Frames[_hoverIndex];
|
||||
string[] lines =
|
||||
[
|
||||
var detail = new List<string>
|
||||
{
|
||||
$"#{_hoverIndex + 1} {record.FileName}",
|
||||
$"misurata {_curve.Measured[_hoverIndex]:0.00} EV",
|
||||
$"target {_curve.Target[_hoverIndex]:0.00} EV",
|
||||
$"guadagno {_curve.GainStops[_hoverIndex]:+0.00;-0.00;0.00} EV",
|
||||
$"intervallo {record.CadenceText} otturatore {record.ShutterAngle:0.#}°",
|
||||
];
|
||||
};
|
||||
|
||||
if (_curve.HasRegions && _curve.MeasuredHigh is { } high && _curve.MeasuredLow is { } low)
|
||||
detail.Add($"cielo {high[_hoverIndex]:0.00} EV paesaggio {low[_hoverIndex]:0.00} EV");
|
||||
|
||||
if (!double.IsNaN(record.TemperatureKelvin))
|
||||
detail.Add($"temperatura {Analysis.ColorScience.Describe(record.TemperatureKelvin)}");
|
||||
|
||||
if (record.StabilizationShift > 0.01)
|
||||
detail.Add($"stabilizzazione {record.StabilizationShift:0.0} px, " +
|
||||
$"{record.StabilizationRotation:0.00}°");
|
||||
|
||||
if (record.IsExposureStep) detail.Add("qui la macchina ha cambiato impostazioni");
|
||||
|
||||
string[] lines = [.. detail];
|
||||
|
||||
int widthNeeded = 0;
|
||||
foreach (string line in lines)
|
||||
|
||||
+88
-13
@@ -12,7 +12,7 @@ internal sealed class MainForm : Form
|
||||
private readonly LuminanceChart _chart;
|
||||
private readonly FrameTable _table;
|
||||
private readonly PreviewPanel _preview;
|
||||
private readonly SettingsPanel _settings;
|
||||
private SettingsPanel _settings;
|
||||
private readonly DarkProgressBar _progress;
|
||||
private readonly Label _status;
|
||||
private readonly Label _summary;
|
||||
@@ -285,10 +285,7 @@ internal sealed class MainForm : Form
|
||||
ShowPreview(_table.SelectedIndex);
|
||||
};
|
||||
|
||||
_settings.DeflickerChanged += (_, _) => RecomputeCurve();
|
||||
_settings.AnalysisInvalidated += (_, _) => InvalidateAnalysis();
|
||||
_settings.PreviewInvalidated += (_, _) => ShowPreview(_table.SelectedIndex);
|
||||
_settings.BrowseOutputRequested += (_, _) => BrowseOutput();
|
||||
WireSettingsEvents();
|
||||
|
||||
DragEnter += (_, e) =>
|
||||
{
|
||||
@@ -300,6 +297,14 @@ internal sealed class MainForm : Form
|
||||
};
|
||||
}
|
||||
|
||||
private void WireSettingsEvents()
|
||||
{
|
||||
_settings.DeflickerChanged += (_, _) => RecomputeCurve();
|
||||
_settings.AnalysisInvalidated += (_, _) => InvalidateAnalysis();
|
||||
_settings.PreviewInvalidated += (_, _) => ShowPreview(_table.SelectedIndex);
|
||||
_settings.BrowseOutputRequested += (_, _) => BrowseOutput();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ comandi
|
||||
|
||||
private void AddFiles()
|
||||
@@ -352,10 +357,11 @@ internal sealed class MainForm : Form
|
||||
var sequence = await RenderPipeline.IngestAsync(paths, _project.General.CadenceTolerance,
|
||||
progress, _operation!.Token);
|
||||
_project.Sequence = sequence;
|
||||
_project.Curve = null;
|
||||
_project.Stats = null;
|
||||
_project.InvalidateAnalysis();
|
||||
_project.DetectOrientation();
|
||||
_settings.ShowDetectedOrientation(_project.Orientation);
|
||||
_settings.ShowSequenceGeometry();
|
||||
_settings.ShowAnalysis();
|
||||
|
||||
_table.SetSequence(sequence);
|
||||
_chart.SetData(sequence, null);
|
||||
@@ -386,8 +392,8 @@ internal sealed class MainForm : Form
|
||||
{
|
||||
if (_busy) return;
|
||||
_project.Sequence = null;
|
||||
_project.Curve = null;
|
||||
_project.Stats = null;
|
||||
_project.InvalidateAnalysis();
|
||||
_settings.ShowAnalysis();
|
||||
_table.SetSequence(null);
|
||||
_chart.SetData(null, null);
|
||||
_preview.Clear();
|
||||
@@ -409,6 +415,7 @@ internal sealed class MainForm : Form
|
||||
|
||||
_chart.SetData(_project.Sequence, _project.Curve);
|
||||
_table.Refresh(_project.Sequence);
|
||||
_settings.ShowAnalysis();
|
||||
UpdateSummary();
|
||||
ShowPreview(_table.SelectedIndex);
|
||||
|
||||
@@ -419,7 +426,8 @@ internal sealed class MainForm : Form
|
||||
double after = Analysis.DeflickerCurve.FlickerIndex(corrected);
|
||||
|
||||
SetStatus($"Analisi completata. Sfarfallio {before:0.000} EV → {after:0.000} EV " +
|
||||
$"({100 * (1 - after / Math.Max(before, 1e-9)):0.#}% di riduzione).");
|
||||
$"({100 * (1 - after / Math.Max(before, 1e-9)):0.#}% di riduzione)." +
|
||||
DescribeAdvancedAnalysis());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -455,7 +463,12 @@ internal sealed class MainForm : Form
|
||||
_table.Refresh(_project.Sequence);
|
||||
SetStatus($"Esportazione completata: {result.EncodedFrames} fotogrammi, " +
|
||||
$"{result.OutputBytes / (1024.0 * 1024.0):0.0} MiB in {result.Elapsed.TotalSeconds:0.0} s " +
|
||||
$"con {result.EncoderName}{(result.HardwareAccelerated ? " (hardware)" : " (software)")}.");
|
||||
$"con {result.EncoderName}{(result.HardwareAccelerated ? " (hardware)" : " (software)")}. " +
|
||||
char.ToUpper(result.PlanDescription[0]) + result.PlanDescription[1..] + "." +
|
||||
(result.UsedDisk
|
||||
? $" {result.SpilledFrames} fotogrammi sono passati dal parcheggio su disco " +
|
||||
$"({result.SpillBytes / (1024.0 * 1024.0):0} MiB)."
|
||||
: string.Empty));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -506,6 +519,7 @@ internal sealed class MainForm : Form
|
||||
new RenderPipeline(_project).RecomputeCurve();
|
||||
_chart.UpdateCurve(_project.Curve);
|
||||
_table.Refresh(_project.Sequence);
|
||||
_settings.ShowAnalysis();
|
||||
UpdateSummary();
|
||||
ShowPreview(_table.SelectedIndex);
|
||||
}
|
||||
@@ -513,14 +527,42 @@ internal sealed class MainForm : Form
|
||||
private void InvalidateAnalysis()
|
||||
{
|
||||
if (_project.Sequence is { } sequence) sequence.RecomputeTiming(_project.General.CadenceTolerance);
|
||||
_project.Curve = null;
|
||||
_project.Stats = null;
|
||||
_project.InvalidateAnalysis();
|
||||
_chart.SetData(_project.Sequence, null);
|
||||
_table.Refresh(_project.Sequence);
|
||||
_settings.ShowSequenceGeometry();
|
||||
_settings.ShowAnalysis();
|
||||
UpdateSummary();
|
||||
UpdateCommandState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cenno compatto a quello che i moduli avanzati hanno trovato. Il dettaglio vive nella
|
||||
/// sezione "Esito dell'analisi" del pannello: la barra di stato ha una riga sola, e
|
||||
/// riempirla di numeri significa troncarli tutti.
|
||||
/// </summary>
|
||||
private string DescribeAdvancedAnalysis()
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (_project.Mask is { Coverage: var coverage }) parts.Add($"cielo {coverage * 100:0}%");
|
||||
|
||||
if (_project.Transitions is { StepCount: > 0 } transitions)
|
||||
{
|
||||
parts.Add(transitions.StepCount == 1
|
||||
? "1 cambio di esposizione"
|
||||
: $"{transitions.StepCount} cambi di esposizione");
|
||||
}
|
||||
|
||||
if (_project.Motion is { } motion)
|
||||
{
|
||||
var (width, _) = _project.ResolveSourceSize();
|
||||
parts.Add($"tremolio {motion.MeanShake * Math.Max(1, width):0.0} px");
|
||||
}
|
||||
|
||||
return parts.Count == 0 ? string.Empty : " " + string.Join(" · ", parts) + ".";
|
||||
}
|
||||
|
||||
private void ShowPreview(int index)
|
||||
{
|
||||
if (_project.Sequence is not { Count: > 0 } sequence || index < 0) { _preview.Clear(); return; }
|
||||
@@ -627,4 +669,37 @@ internal sealed class MainForm : Form
|
||||
await AnalyzeAsync();
|
||||
_table.SelectedIndex = Math.Min(12, Math.Max(0, (_project.Sequence?.Count ?? 1) - 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accende i moduli avanzati prima di una cattura, così l'immagine mostra i controlli con
|
||||
/// dati veri invece che a riposo. Serve solo alla verifica riproducibile dell'interfaccia.
|
||||
/// </summary>
|
||||
internal void EnableAdvancedModulesForCapture()
|
||||
{
|
||||
_project.Regions.Mode = Analysis.RegionMode.SkyGround;
|
||||
_project.HolyGrail.Enabled = true;
|
||||
_project.HolyGrail.TransitionFrames = 24;
|
||||
_project.HolyGrail.SmoothColor = true;
|
||||
_project.Stabilization.Enabled = true;
|
||||
_project.Camera.Enabled = true;
|
||||
_project.Camera.Keyframes =
|
||||
[
|
||||
new() { Time = 0.0, CentreX = 0.38, CentreY = 0.42, Zoom = 1.20 },
|
||||
new() { Time = 0.55, CentreX = 0.52, CentreY = 0.50, Zoom = 1.45, EaseIn = 0.7, EaseOut = 0.2 },
|
||||
new() { Time = 1.0, CentreX = 0.68, CentreY = 0.58, Zoom = 1.80 },
|
||||
];
|
||||
_project.TimeRamp.Enabled = true;
|
||||
_project.TimeRamp.Speed = [new(0.0, 2.5), new(0.45, 0.4), new(1.0, 2.0)];
|
||||
_project.Stacking.Mode = Motion.StackingMode.Median;
|
||||
|
||||
// I pannelli leggono il progetto alla costruzione: qui vanno rifatti da capo.
|
||||
var host = _settings.Parent;
|
||||
int page = 0;
|
||||
host?.Controls.Remove(_settings);
|
||||
_settings.Dispose();
|
||||
_settings = new SettingsPanel(_project) { Dock = DockStyle.Fill };
|
||||
host?.Controls.Add(_settings);
|
||||
WireSettingsEvents();
|
||||
_settings.SelectPage(page);
|
||||
}
|
||||
}
|
||||
|
||||
+109
-9
@@ -21,6 +21,9 @@ internal sealed class PreviewPanel : Control
|
||||
private int _requestId;
|
||||
private bool _busy;
|
||||
|
||||
/// <summary>Contorno delle regioni in coordinate normalizzate dell'anteprima.</summary>
|
||||
private PointF[]? _regionContour;
|
||||
|
||||
public PreviewPanel(TitanoProject project)
|
||||
{
|
||||
_project = project;
|
||||
@@ -61,7 +64,7 @@ internal sealed class PreviewPanel : Control
|
||||
{
|
||||
try
|
||||
{
|
||||
var (bitmap, status) = Render(project, sequence, index, PreviewSize(), token);
|
||||
var (bitmap, status, contour) = Render(project, sequence, index, PreviewSize(), token);
|
||||
if (token.IsCancellationRequested || requestId != Volatile.Read(ref _requestId))
|
||||
{
|
||||
bitmap?.Dispose();
|
||||
@@ -72,6 +75,7 @@ internal sealed class PreviewPanel : Control
|
||||
{
|
||||
if (requestId != Volatile.Read(ref _requestId)) { bitmap?.Dispose(); return; }
|
||||
SwapBitmap(bitmap);
|
||||
_regionContour = contour;
|
||||
_status = status;
|
||||
_busy = false;
|
||||
Invalidate();
|
||||
@@ -111,8 +115,8 @@ internal sealed class PreviewPanel : Control
|
||||
|
||||
// ------------------------------------------------------------------ rendering
|
||||
|
||||
private static (Bitmap? Bitmap, string Status) Render(TitanoProject project, TimelapseSequence sequence,
|
||||
int index, Size available, CancellationToken token)
|
||||
private static (Bitmap? Bitmap, string Status, PointF[]? Contour) Render(
|
||||
TitanoProject project, TimelapseSequence sequence, int index, Size available, CancellationToken token)
|
||||
{
|
||||
var record = sequence.Frames[index];
|
||||
var metadata = record.Metadata;
|
||||
@@ -125,7 +129,7 @@ internal sealed class PreviewPanel : Control
|
||||
else if (ImageDecoder.SwapsAxes(orientation))
|
||||
(sourceWidth, sourceHeight) = (sourceHeight, sourceWidth);
|
||||
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) return (null, "Immagine non leggibile");
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) return (null, "Immagine non leggibile", null);
|
||||
|
||||
double scale = Math.Min(available.Width / (double)sourceWidth, available.Height / (double)sourceHeight);
|
||||
scale = Math.Min(scale, 1.0);
|
||||
@@ -142,8 +146,7 @@ internal sealed class PreviewPanel : Control
|
||||
var curve = project.Curve;
|
||||
if (project.Deflicker.Enabled && curve is not null && index < curve.Count)
|
||||
{
|
||||
Analysis.ExposureProcessor.Apply(frame, curve.ChannelGain[index],
|
||||
project.Deflicker.ProtectHighlights, project.Deflicker.HighlightKnee);
|
||||
ApplyExposure(project, curve, index, frame);
|
||||
status.Append($" guadagno {curve.GainStops[index]:+0.00;-0.00;0.00} EV");
|
||||
}
|
||||
|
||||
@@ -158,8 +161,7 @@ internal sealed class PreviewPanel : Control
|
||||
|
||||
if (project.Deflicker.Enabled && curve is not null && index + 1 < curve.Count)
|
||||
{
|
||||
Analysis.ExposureProcessor.Apply(next, curve.ChannelGain[index + 1],
|
||||
project.Deflicker.ProtectHighlights, project.Deflicker.HighlightKnee);
|
||||
ApplyExposure(project, curve, index + 1, next);
|
||||
}
|
||||
|
||||
var flow = new OpticalFlowEngine(project.Flow).Compute(frame, next);
|
||||
@@ -180,9 +182,92 @@ internal sealed class PreviewPanel : Control
|
||||
status.Append($" scia {length:0.0} px");
|
||||
}
|
||||
|
||||
// Inquadratura virtuale e stabilizzazione: l'anteprima deve mostrare il fotogramma
|
||||
// come uscirà, altrimenti si regola una panoramica guardando ciò che non si esporta.
|
||||
ImageBuffer? framed = null;
|
||||
PointF[]? contour = null;
|
||||
var mapping = SourceMapping.Identity;
|
||||
int framedWidth = width, framedHeight = height;
|
||||
|
||||
if (project.NeedsGeometry)
|
||||
{
|
||||
var (outputWidth, outputHeight) = project.ResolveWorkingSize();
|
||||
if (outputWidth > 0 && outputHeight > 0)
|
||||
{
|
||||
framedWidth = width;
|
||||
framedHeight = Math.Max(2, (int)Math.Round(width * outputHeight / (double)outputWidth) & ~1);
|
||||
|
||||
double normalized = sequence.Count > 1 ? index / (double)(sequence.Count - 1) : 0;
|
||||
var framing = project.FramingAt(normalized);
|
||||
var stabilization = project.Motion?.At(index) ?? Motion.SimilarityTransform.Identity;
|
||||
|
||||
mapping = GeometryStage.Build(width, height, framedWidth, framedHeight, framing, stabilization);
|
||||
framed = pool.Rent(framedWidth, framedHeight);
|
||||
GeometryStage.Resample(result, framed, mapping);
|
||||
result = framed;
|
||||
|
||||
status.Append($" inquadratura {framing.Zoom:0.00}×");
|
||||
}
|
||||
}
|
||||
|
||||
if (project.Mask is { } mask)
|
||||
{
|
||||
contour = TraceRegionBoundary(mask, mapping, framedWidth, framedHeight, width, height);
|
||||
}
|
||||
|
||||
var bitmap = ToBitmap(result);
|
||||
blurred?.Dispose();
|
||||
return (bitmap, status.ToString());
|
||||
framed?.Dispose();
|
||||
return (bitmap, status.ToString(), contour);
|
||||
}
|
||||
|
||||
private static void ApplyExposure(TitanoProject project, Analysis.DeflickerCurve curve,
|
||||
int index, ImageBuffer frame)
|
||||
{
|
||||
if (curve.HasRegions && project.Mask is { } mask)
|
||||
{
|
||||
Analysis.ExposureProcessor.ApplyRegional(frame, mask, curve.ChannelGainHigh![index],
|
||||
curve.ChannelGainLow![index],
|
||||
project.Deflicker.ProtectHighlights,
|
||||
project.Deflicker.HighlightKnee);
|
||||
return;
|
||||
}
|
||||
|
||||
Analysis.ExposureProcessor.Apply(frame, curve.ChannelGain[index],
|
||||
project.Deflicker.ProtectHighlights, project.Deflicker.HighlightKnee);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confine fra le due regioni, in coordinate normalizzate dell'immagine mostrata. Il
|
||||
/// campionamento passa per la stessa mappatura del ritaglio, così la linea resta al suo
|
||||
/// posto anche quando l'anteprima mostra una panoramica virtuale.
|
||||
/// </summary>
|
||||
private static PointF[]? TraceRegionBoundary(Analysis.RegionMask mask, in SourceMapping mapping,
|
||||
int width, int height, int sourceWidth, int sourceHeight)
|
||||
{
|
||||
var points = new List<PointF>(width);
|
||||
float invSourceWidth = sourceWidth > 1 ? 1f / (sourceWidth - 1) : 0f;
|
||||
float invSourceHeight = sourceHeight > 1 ? 1f / (sourceHeight - 1) : 0f;
|
||||
|
||||
for (int x = 0; x < width; x += 2)
|
||||
{
|
||||
float previous = float.NaN;
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
var (sx, sy) = mapping.Apply(x, y);
|
||||
float weight = mask.Sample((float)sx * invSourceWidth, (float)sy * invSourceHeight);
|
||||
|
||||
if (!float.IsNaN(previous) && (previous - 0.5f) * (weight - 0.5f) <= 0)
|
||||
{
|
||||
points.Add(new PointF(x / (float)Math.Max(1, width - 1),
|
||||
y / (float)Math.Max(1, height - 1)));
|
||||
break;
|
||||
}
|
||||
previous = weight;
|
||||
}
|
||||
}
|
||||
|
||||
return points.Count >= 2 ? [.. points] : null;
|
||||
}
|
||||
|
||||
/// <summary>Conversione dal buffer in luce lineare a una bitmap GDI+ a 32 bit.</summary>
|
||||
@@ -245,6 +330,21 @@ internal sealed class PreviewPanel : Control
|
||||
g.DrawImage(_bitmap, target);
|
||||
using (var pen = new Pen(Theme.Border)) g.DrawRectangle(pen, target);
|
||||
|
||||
if (_regionContour is { Length: >= 2 } contour)
|
||||
{
|
||||
var line = new PointF[contour.Length];
|
||||
for (int i = 0; i < contour.Length; i++)
|
||||
{
|
||||
line[i] = new PointF(target.Left + contour[i].X * target.Width,
|
||||
target.Top + contour[i].Y * target.Height);
|
||||
}
|
||||
|
||||
using var shadow = new Pen(Color.FromArgb(140, Color.Black), 3f);
|
||||
using var boundary = new Pen(Color.FromArgb(210, Theme.Success), 1.6f);
|
||||
g.DrawLines(shadow, line);
|
||||
g.DrawLines(boundary, line);
|
||||
}
|
||||
|
||||
if (_busy)
|
||||
{
|
||||
using var overlay = new SolidBrush(Color.FromArgb(120, Theme.Background));
|
||||
|
||||
+412
-51
@@ -1,13 +1,15 @@
|
||||
using Titano.Analysis;
|
||||
using Titano.Motion;
|
||||
using Titano.Pipeline;
|
||||
using Titano.Video;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Pannello di configurazione avanzata, suddiviso nelle tre sezioni richieste:
|
||||
/// Generale, Elaborazione immagini (deflicker, motion blur, campo vettoriale) ed
|
||||
/// Esportazione video. Ogni controllo scrive direttamente nel progetto e segnala
|
||||
/// quale parte della pipeline va ricalcolata.
|
||||
/// Pannello di configurazione avanzata, diviso nelle sezioni Generale, Immagine, Movimento,
|
||||
/// Tempo ed Esportazione. Ogni controllo scrive direttamente nel progetto e dichiara quale
|
||||
/// parte della pipeline va rifatta: c'è differenza fra spostare un cursore che cambia solo
|
||||
/// una curva già calcolata e sceglierne uno che obbliga a rileggere ogni file.
|
||||
/// </summary>
|
||||
internal sealed class SettingsPanel : Panel
|
||||
{
|
||||
@@ -17,9 +19,19 @@ internal sealed class SettingsPanel : Panel
|
||||
private readonly TitanoProject _project;
|
||||
private readonly TabStrip _tabs;
|
||||
private readonly Panel[] _pages;
|
||||
private LabeledCombo? _orientationCombo;
|
||||
|
||||
/// <summary>Un parametro del deflicker è cambiato: basta ricalcolare la curva.</summary>
|
||||
private LabeledCombo? _orientationCombo;
|
||||
private Label? _analysisNote;
|
||||
private CameraEditor? _cameraEditor;
|
||||
private SplineEditor? _rampEditor;
|
||||
private ParameterSlider? _keyframeCentreX;
|
||||
private ParameterSlider? _keyframeCentreY;
|
||||
private ParameterSlider? _keyframeZoom;
|
||||
private ParameterSlider? _keyframeEaseOut;
|
||||
private ParameterSlider? _keyframeEaseIn;
|
||||
private bool _syncingKeyframe;
|
||||
|
||||
/// <summary>Un parametro del deflicker è cambiato: basta ricalcolare le curve già misurate.</summary>
|
||||
public event EventHandler? DeflickerChanged;
|
||||
|
||||
/// <summary>È cambiato un parametro che invalida l'analisi già svolta.</summary>
|
||||
@@ -38,7 +50,10 @@ internal sealed class SettingsPanel : Panel
|
||||
BackColor = Theme.Surface;
|
||||
Padding = new Padding(0);
|
||||
|
||||
_tabs = new TabStrip("Generale", "Elaborazione immagini", "Esportazione") { Dock = DockStyle.Top };
|
||||
_tabs = new TabStrip("Generale", "Immagine", "Movimento", "Tempo", "Esportazione")
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
};
|
||||
_tabs.SelectedChanged += (_, _) => ShowPage(_tabs.SelectedIndex);
|
||||
|
||||
OutputPathBox = new TextBox
|
||||
@@ -58,7 +73,7 @@ internal sealed class SettingsPanel : Panel
|
||||
// L'ordine di inserimento determina l'ordine di ancoraggio: i controlli in coda alla
|
||||
// collezione vengono disposti per primi, quindi la barra delle schede va aggiunta
|
||||
// dopo le pagine per riservarsi la propria fascia in alto.
|
||||
_pages = [BuildGeneralPage(), BuildImagePage(), BuildExportPage()];
|
||||
_pages = [BuildGeneralPage(), BuildImagePage(), BuildMotionPage(), BuildTimePage(), BuildExportPage()];
|
||||
foreach (var page in _pages)
|
||||
{
|
||||
page.Dock = DockStyle.Fill;
|
||||
@@ -82,7 +97,52 @@ internal sealed class SettingsPanel : Panel
|
||||
_orientationCombo.Combo.SelectedIndex = selected;
|
||||
}
|
||||
|
||||
/// <summary>Seleziona una delle tre sezioni; usata anche dalla modalità di cattura.</summary>
|
||||
/// <summary>Aggiorna le voci che dipendono dall'analisi: maschera, transizioni, tremolio.</summary>
|
||||
public void ShowAnalysis()
|
||||
{
|
||||
if (_analysisNote is null) return;
|
||||
|
||||
var lines = new List<string>();
|
||||
|
||||
if (_project.Regions.Mode != RegionMode.Off)
|
||||
{
|
||||
lines.Add(_project.Mask is { } mask
|
||||
? "Regioni: " + mask.Description
|
||||
: "Regioni: la scena non si divide in modo utile, resta la curva unica.");
|
||||
}
|
||||
|
||||
if (_project.HolyGrail.Enabled && _project.Transitions is { } transitions)
|
||||
{
|
||||
lines.Add(transitions.StepCount == 0
|
||||
? "Transizioni: nessun cambio di impostazione rilevato."
|
||||
: $"Transizioni: {transitions.StepCount} cambi di impostazione, " +
|
||||
$"il maggiore di {transitions.LargestStepStops:0.00} EV" +
|
||||
(transitions.MetadataUsable ? " (dai metadati)." : " (dedotti dalla luminanza)."));
|
||||
}
|
||||
|
||||
if (_project.Stabilization.Enabled && _project.Motion is { } motion)
|
||||
{
|
||||
var (width, _) = _project.ResolveSourceSize();
|
||||
lines.Add($"Stabilizzazione: tremolio medio {motion.MeanShake * Math.Max(1, width):0.0} px, " +
|
||||
$"ritaglio necessario {(_project.StabilizationZoom - 1) * 100:0.#}%.");
|
||||
}
|
||||
|
||||
_analysisNote.Text = lines.Count > 0
|
||||
? string.Join(Environment.NewLine, lines)
|
||||
: "Esegui l'analisi per vedere cosa il motore ha dedotto dalla sequenza.";
|
||||
LayoutNote(_analysisNote);
|
||||
}
|
||||
|
||||
/// <summary>Aggiorna l'editor del movimento quando cambia la sequenza caricata.</summary>
|
||||
public void ShowSequenceGeometry()
|
||||
{
|
||||
if (_cameraEditor is null) return;
|
||||
var (width, height) = _project.ResolveNativeSize();
|
||||
if (width > 0 && height > 0) _cameraEditor.Aspect = width / (double)height;
|
||||
_cameraEditor.Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>Seleziona una delle sezioni; usata anche dalla modalità di cattura.</summary>
|
||||
internal void SelectPage(int index) => _tabs.SelectedIndex = index;
|
||||
|
||||
private void ShowPage(int index)
|
||||
@@ -90,7 +150,7 @@ internal sealed class SettingsPanel : Panel
|
||||
for (int i = 0; i < _pages.Length; i++) _pages[i].Visible = i == index;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ pagine
|
||||
// ------------------------------------------------------------------ Generale
|
||||
|
||||
private Panel BuildGeneralPage()
|
||||
{
|
||||
@@ -140,11 +200,11 @@ internal sealed class SettingsPanel : Panel
|
||||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Note("Il profilo governa finezza del campo vettoriale, campioni della sfocatura e " +
|
||||
"risoluzione della passata fotometrica, e sovrascrive i cursori delle sezioni " +
|
||||
"avanzate. Più qualità significa più tempo, non un file più grande."));
|
||||
stack.Add(Note("Il profilo governa finezza del campo vettoriale, campioni della sfocatura, " +
|
||||
"riquadri della correlazione di fase e risoluzione delle passate di analisi, " +
|
||||
"e sovrascrive i cursori delle sezioni avanzate."));
|
||||
|
||||
stack.Add(new SectionHeader("Prestazioni"));
|
||||
stack.Add(new SectionHeader("Prestazioni e memoria"));
|
||||
stack.Add(Slider("Larghezza della passata di analisi", 256, 2048, _project.General.AnalysisWidth, 64, "0", "px",
|
||||
value =>
|
||||
{
|
||||
@@ -159,24 +219,40 @@ internal sealed class SettingsPanel : Panel
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Note("Le decodifiche simultanee determinano anche quanti fotogrammi restano " +
|
||||
"contemporaneamente in memoria: l'occupazione non dipende dalla lunghezza della sequenza."));
|
||||
stack.Add(Slider("Lettura in anticipo", 0, 32, _project.Cache.PrefetchDepth, 1, "0", "fotogrammi",
|
||||
value => _project.Cache.PrefetchDepth = (int)value));
|
||||
|
||||
stack.Add(Slider("Tetto di memoria per i fotogrammi", 256, 32768, _project.Cache.MemoryBudgetMiB, 256, "0", "MiB",
|
||||
value => _project.Cache.MemoryBudgetMiB = (int)value));
|
||||
|
||||
stack.Add(Check("Parcheggia su disco i fotogrammi in eccesso", _project.Cache.AllowDiskSpill,
|
||||
value => _project.Cache.AllowDiskSpill = value));
|
||||
|
||||
stack.Add(Note("La finestra attiva sta sempre in memoria; il tetto governa la lettura in " +
|
||||
"anticipo, che serve a tenere occupati tutti i processori sulla decodifica dei " +
|
||||
"RAW. Oltre il tetto i fotogrammi già letti aspettano su disco e vengono ripresi " +
|
||||
"una volta sola. Il file di parcheggio si cancella da sé alla chiusura."));
|
||||
|
||||
stack.Add(new SectionHeader("Esito dell'analisi"));
|
||||
_analysisNote = Note("Esegui l'analisi per vedere cosa il motore ha dedotto dalla sequenza.");
|
||||
stack.Add(_analysisNote);
|
||||
|
||||
return stack.Panel;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Immagine
|
||||
|
||||
private Panel BuildImagePage()
|
||||
{
|
||||
var stack = NewStack();
|
||||
|
||||
// ---- Deflicker
|
||||
stack.Add(new SectionHeader("Deflicker"));
|
||||
|
||||
var deflickerEnabled = Check("Correzione dell'esposizione attiva", _project.Deflicker.Enabled, value =>
|
||||
stack.Add(Check("Correzione dell'esposizione attiva", _project.Deflicker.Enabled, value =>
|
||||
{
|
||||
_project.Deflicker.Enabled = value;
|
||||
DeflickerChanged?.Invoke(this, EventArgs.Empty);
|
||||
});
|
||||
stack.Add(deflickerEnabled);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Finestra temporale", 3, 121, _project.Deflicker.WindowFrames, 2, "0", "fotogrammi",
|
||||
value => { _project.Deflicker.WindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||||
@@ -193,12 +269,6 @@ internal sealed class SettingsPanel : Panel
|
||||
DeflickerChanged?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Check("Stabilizza il bilanciamento colore", _project.Deflicker.StabilizeColor, value =>
|
||||
{
|
||||
_project.Deflicker.StabilizeColor = value;
|
||||
DeflickerChanged?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Check("Proteggi le alte luci", _project.Deflicker.ProtectHighlights, value =>
|
||||
{
|
||||
_project.Deflicker.ProtectHighlights = value;
|
||||
@@ -208,7 +278,182 @@ internal sealed class SettingsPanel : Panel
|
||||
stack.Add(Slider("Innesco della compressione", 0.4, 0.98, _project.Deflicker.HighlightKnee, 0.02, "0.00", string.Empty,
|
||||
value => { _project.Deflicker.HighlightKnee = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
// ---- Motion blur
|
||||
// ---- Regioni
|
||||
stack.Add(new SectionHeader("Deflicker per regioni"));
|
||||
|
||||
stack.Add(Combo("Divisione del fotogramma",
|
||||
["Nessuna — una sola curva", "Cielo e paesaggio (linea d'orizzonte)", "Per luminanza"],
|
||||
_project.Regions.Mode switch { RegionMode.SkyGround => 1, RegionMode.Luminance => 2, _ => 0 },
|
||||
index =>
|
||||
{
|
||||
_project.Regions.Mode = index switch
|
||||
{
|
||||
1 => RegionMode.SkyGround,
|
||||
2 => RegionMode.Luminance,
|
||||
_ => RegionMode.Off,
|
||||
};
|
||||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Indipendenza delle regioni", 0, 1, _project.Regions.Independence, 0.05, "0.00", string.Empty,
|
||||
value => { _project.Regions.Independence = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Sfumatura del confine", 0.01, 0.20, _project.Regions.Feather, 0.01, "0.00", string.Empty,
|
||||
value => { _project.Regions.Feather = value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Fotogrammi campionati", 3, 48, _project.Regions.SampleFrames, 1, "0", string.Empty,
|
||||
value => { _project.Regions.SampleFrames = (int)value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Note("La maschera nasce dalla mediana temporale di un campione di fotogrammi, che " +
|
||||
"toglie di mezzo nuvole e passanti e lascia la struttura fissa della scena. " +
|
||||
"Serve a impedire che il transito di una nuvola densa sul cielo faccia " +
|
||||
"schiarire anche il paesaggio, che invece non è cambiato."));
|
||||
|
||||
// ---- Holy Grail
|
||||
stack.Add(new SectionHeader("Transizioni giorno-notte"));
|
||||
|
||||
stack.Add(Check("Ammorbidisci i cambi di impostazione", _project.HolyGrail.Enabled, value =>
|
||||
{
|
||||
_project.HolyGrail.Enabled = value;
|
||||
DeflickerChanged?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Lunghezza della transizione", 4, 240, _project.HolyGrail.TransitionFrames, 2, "0", "fotogrammi",
|
||||
value => { _project.HolyGrail.TransitionFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Soglia di riconoscimento", 0.05, 1.0, _project.HolyGrail.StepThresholdStops, 0.05, "0.00", "EV",
|
||||
value => { _project.HolyGrail.StepThresholdStops = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Check("Ricava i salti dai metadati", _project.HolyGrail.UseMetadata, value =>
|
||||
{
|
||||
_project.HolyGrail.UseMetadata = value;
|
||||
DeflickerChanged?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Check("Liscia il bilanciamento del bianco", _project.HolyGrail.SmoothColor, value =>
|
||||
{
|
||||
_project.HolyGrail.SmoothColor = value;
|
||||
DeflickerChanged?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Finestra della lisciatura cromatica", 5, 201, _project.HolyGrail.ColorWindowFrames, 2, "0", "fotogrammi",
|
||||
value => { _project.HolyGrail.ColorWindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Intensità cromatica", 0, 1, _project.HolyGrail.ColorStrength, 0.05, "0.00", string.Empty,
|
||||
value => { _project.HolyGrail.ColorStrength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Note("Il valore di esposizione si legge nei metadati, quindi l'ampiezza di ogni " +
|
||||
"salto è nota senza incertezza e viene ridistribuita su una transizione a " +
|
||||
"derivata nulla agli estremi. La lisciatura cromatica agisce sul rapporto fra " +
|
||||
"i canali: toglie il tremolio del bilanciamento automatico e lascia intatto " +
|
||||
"il viaggio verso il caldo del tramonto."));
|
||||
|
||||
return stack.Panel;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Movimento
|
||||
|
||||
private Panel BuildMotionPage()
|
||||
{
|
||||
var stack = NewStack();
|
||||
|
||||
stack.Add(new SectionHeader("Stabilizzazione sub-pixel"));
|
||||
|
||||
stack.Add(Check("Compensa i micro-urti", _project.Stabilization.Enabled, value =>
|
||||
{
|
||||
_project.Stabilization.Enabled = value;
|
||||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Finestra del percorso", 5, 151, _project.Stabilization.SmoothingFrames, 2, "0", "fotogrammi",
|
||||
value => { _project.Stabilization.SmoothingFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Intensità", 0, 1, _project.Stabilization.Strength, 0.05, "0.00", string.Empty,
|
||||
value => { _project.Stabilization.Strength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Correzione massima", 0.005, 0.20, _project.Stabilization.MaxCorrectionFraction, 0.005, "0.000", "×L",
|
||||
value => { _project.Stabilization.MaxCorrectionFraction = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Check("Compensa anche la rotazione", _project.Stabilization.CompensateRotation, value =>
|
||||
{
|
||||
_project.Stabilization.CompensateRotation = value;
|
||||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Lato dei riquadri di correlazione", 64, 512, _project.Stabilization.PatchSize, 64, "0", "px",
|
||||
value =>
|
||||
{
|
||||
// La trasformata vuole una potenza di due: il cursore si muove per gradini validi.
|
||||
_project.Stabilization.PatchSize = Fourier.FloorPowerOfTwo((int)value);
|
||||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Riquadri per lato", 1, 5, _project.Stabilization.Grid, 1, "0", string.Empty,
|
||||
value => { _project.Stabilization.Grid = (int)value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Note("Lo spostamento fra fotogrammi adiacenti si misura con la correlazione di " +
|
||||
"fase, che ignora le differenze di luminosità e reagisce alla sola geometria. " +
|
||||
"Il percorso ricostruito viene lisciato: quello che resta fra percorso vero e " +
|
||||
"percorso liscio è il tremolio, e la sua inversa è la correzione. Una " +
|
||||
"panoramica voluta sopravvive perché è già liscia."));
|
||||
|
||||
// ---- Virtual camera
|
||||
stack.Add(new SectionHeader("Movimento di macchina virtuale"));
|
||||
|
||||
stack.Add(Check("Panoramiche e zoom virtuali", _project.Camera.Enabled, value =>
|
||||
{
|
||||
_project.Camera.Enabled = value;
|
||||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
_cameraEditor = new CameraEditor { Settings = _project.Camera, Height = 210 };
|
||||
_cameraEditor.Changed += (_, _) =>
|
||||
{
|
||||
SyncKeyframeControls();
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
};
|
||||
_cameraEditor.SelectionChanged += (_, _) => SyncKeyframeControls();
|
||||
stack.Add(_cameraEditor);
|
||||
|
||||
var keyframeButtons = new Panel { Height = 32, BackColor = Theme.Surface };
|
||||
var addButton = new DarkButton { Text = "Aggiungi nodo", Width = 130, Height = 28, Left = 0, Top = 0 };
|
||||
var removeButton = new DarkButton { Text = "Togli nodo", Width = 118, Height = 28, Left = 138, Top = 0 };
|
||||
addButton.Click += (_, _) => { _cameraEditor.AddKeyframe(); PreviewInvalidated?.Invoke(this, EventArgs.Empty); };
|
||||
removeButton.Click += (_, _) => { _cameraEditor.RemoveSelected(); PreviewInvalidated?.Invoke(this, EventArgs.Empty); };
|
||||
keyframeButtons.Controls.Add(addButton);
|
||||
keyframeButtons.Controls.Add(removeButton);
|
||||
stack.Add(keyframeButtons);
|
||||
|
||||
_keyframeCentreX = Slider("Nodo — centro orizzontale", 0, 1, 0.5, 0.005, "0.000", string.Empty,
|
||||
value => UpdateSelectedKeyframe(k => k.CentreX = value));
|
||||
_keyframeCentreY = Slider("Nodo — centro verticale", 0, 1, 0.5, 0.005, "0.000", string.Empty,
|
||||
value => UpdateSelectedKeyframe(k => k.CentreY = value));
|
||||
_keyframeZoom = Slider("Nodo — ingrandimento", 1, 8, 1, 0.05, "0.00", "×",
|
||||
value => UpdateSelectedKeyframe(k => k.Zoom = value));
|
||||
_keyframeEaseOut = Slider("Nodo — indugio in partenza", 0, 1, 0.42, 0.02, "0.00", string.Empty,
|
||||
value => UpdateSelectedKeyframe(k => k.EaseOut = value));
|
||||
_keyframeEaseIn = Slider("Nodo — frenata in arrivo", 0, 1, 0.42, 0.02, "0.00", string.Empty,
|
||||
value => UpdateSelectedKeyframe(k => k.EaseIn = value));
|
||||
|
||||
stack.Add(_keyframeCentreX);
|
||||
stack.Add(_keyframeCentreY);
|
||||
stack.Add(_keyframeZoom);
|
||||
stack.Add(_keyframeEaseOut);
|
||||
stack.Add(_keyframeEaseIn);
|
||||
|
||||
stack.Add(Check("Tieni l'inquadratura dentro il fotogramma", _project.Camera.KeepInsideFrame, value =>
|
||||
{
|
||||
_project.Camera.KeepInsideFrame = value;
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Note("Le inquadrature si trascinano dentro il riquadro e si stringono dalla " +
|
||||
"maniglia d'angolo; la striscia in basso è la linea del tempo. Le tacche " +
|
||||
"lungo il percorso sono equispaziate nel tempo: dove si addensano il " +
|
||||
"movimento rallenta. Con il movimento attivo i fotogrammi vengono letti a " +
|
||||
"risoluzione nativa, perché il ritaglio deve avere pixel da cui attingere."));
|
||||
|
||||
// ---- Motion blur e campo vettoriale
|
||||
stack.Add(new SectionHeader("Motion blur sintetico"));
|
||||
|
||||
stack.Add(Check("Sfocatura di movimento attiva", _project.MotionBlur.Enabled, value =>
|
||||
@@ -232,7 +477,6 @@ internal sealed class SettingsPanel : Panel
|
||||
stack.Add(Note("La scia sintetizzata compensa in quadratura la sfocatura mancante: " +
|
||||
"√(obiettivo² − reale²). A 180° si ottiene la resa cinematografica."));
|
||||
|
||||
// ---- Optical flow
|
||||
stack.Add(new SectionHeader("Campo vettoriale di movimento"));
|
||||
|
||||
stack.Add(Slider("Larghezza di analisi del movimento", 320, 1920, _project.Flow.AnalysisWidth, 32, "0", "px",
|
||||
@@ -250,9 +494,122 @@ internal sealed class SettingsPanel : Panel
|
||||
stack.Add(Slider("Iterazioni per livello", 1, 12, _project.Flow.Iterations, 1, "0", string.Empty,
|
||||
value => { _project.Flow.Iterations = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
SyncKeyframeControls();
|
||||
return stack.Panel;
|
||||
}
|
||||
|
||||
private void UpdateSelectedKeyframe(Action<CameraKeyframe> change)
|
||||
{
|
||||
if (_syncingKeyframe || _cameraEditor?.Selected is not { } keyframe) return;
|
||||
change(keyframe);
|
||||
_cameraEditor.Invalidate();
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Riporta nei cursori i valori del nodo scelto, senza farli reagire.</summary>
|
||||
private void SyncKeyframeControls()
|
||||
{
|
||||
if (_cameraEditor?.Selected is not { } keyframe) return;
|
||||
|
||||
_syncingKeyframe = true;
|
||||
_keyframeCentreX?.SetValueSilently(keyframe.CentreX);
|
||||
_keyframeCentreY?.SetValueSilently(keyframe.CentreY);
|
||||
_keyframeZoom?.SetValueSilently(keyframe.Zoom);
|
||||
_keyframeEaseOut?.SetValueSilently(keyframe.EaseOut);
|
||||
_keyframeEaseIn?.SetValueSilently(keyframe.EaseIn);
|
||||
_syncingKeyframe = false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Tempo
|
||||
|
||||
private Panel BuildTimePage()
|
||||
{
|
||||
var stack = NewStack();
|
||||
|
||||
stack.Add(new SectionHeader("Andamento temporale"));
|
||||
stack.Add(Combo("Durata dei fotogrammi",
|
||||
["Costante — un fotogramma per scatto",
|
||||
"Adattiva — durata proporzionale all'intervallo",
|
||||
"Interpolata — cadenza uniformata con fotogrammi sintetici"],
|
||||
(int)_project.Export.Timing,
|
||||
index =>
|
||||
{
|
||||
_project.Export.Timing = (FrameTimingMode)index;
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Dilatazione massima", 1.5, 8, _project.Export.MaxAdaptiveStretch, 0.5, "0.0", "×",
|
||||
value => _project.Export.MaxAdaptiveStretch = value));
|
||||
|
||||
// ---- Time ramping
|
||||
stack.Add(new SectionHeader("Rimappatura non lineare"));
|
||||
|
||||
stack.Add(Check("Curva di velocità attiva", _project.TimeRamp.Enabled, value =>
|
||||
{
|
||||
_project.TimeRamp.Enabled = value;
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
_rampEditor = new SplineEditor
|
||||
{
|
||||
Minimum = 0.1,
|
||||
Maximum = 8.0,
|
||||
Height = 168,
|
||||
StartLabel = "primo scatto",
|
||||
EndLabel = "ultimo scatto",
|
||||
};
|
||||
_rampEditor.SetKnots(_project.TimeRamp.Speed);
|
||||
_rampEditor.Changed += (_, _) =>
|
||||
{
|
||||
_project.TimeRamp.Speed = [.. _rampEditor.Knots];
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
};
|
||||
stack.Add(_rampEditor);
|
||||
|
||||
stack.Add(Note("Trascina i nodi, aggiungine uno con un doppio clic, toglilo con il tasto destro. " +
|
||||
"La curva dice quanti scatti vengono consumati per ogni fotogramma d'uscita: " +
|
||||
"sopra 1 la sequenza accelera saltando scatti, sotto 1 rallenta e i fotogrammi " +
|
||||
"mancanti vengono sintetizzati dal campo vettoriale. La velocità del filmato " +
|
||||
"resta fissa. La spline è monotona per costruzione, quindi il tempo non può " +
|
||||
"tornare indietro fra due nodi."));
|
||||
|
||||
// ---- Stacking
|
||||
stack.Add(new SectionHeader("Accumulo temporale"));
|
||||
|
||||
stack.Add(Combo("Modalità",
|
||||
["Nessuna", "Mediana — rimuove gli elementi di passaggio", "Massimo — scie stellari"],
|
||||
_project.Stacking.Mode switch { StackingMode.Median => 1, StackingMode.Maximum => 2, _ => 0 },
|
||||
index =>
|
||||
{
|
||||
_project.Stacking.Mode = index switch
|
||||
{
|
||||
1 => StackingMode.Median,
|
||||
2 => StackingMode.Maximum,
|
||||
_ => StackingMode.Off,
|
||||
};
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Finestra della mediana", 3, 49, _project.Stacking.WindowFrames, 2, "0", "fotogrammi",
|
||||
value => { _project.Stacking.WindowFrames = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Intensità dell'accumulo", 0, 1, _project.Stacking.Strength, 0.05, "0.00", string.Empty,
|
||||
value => { _project.Stacking.Strength = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Lunghezza delle scie", 0, 400, _project.Stacking.TrailFrames, 5, "0", "fotogrammi",
|
||||
value => { _project.Stacking.TrailFrames = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Note("La mediana tiene il valore centrale della serie temporale di ogni pixel: la " +
|
||||
"scena stabile resta se stessa, chi attraversa l'inquadratura una volta sola " +
|
||||
"sparisce. Il massimo conserva il valore più alto incontrato e trasforma le " +
|
||||
"stelle in archi continui; a zero le scie non si spengono mai. La finestra " +
|
||||
"della mediana tiene occupata memoria: sono tutti fotogrammi vivi insieme."));
|
||||
|
||||
return stack.Panel;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Esportazione
|
||||
|
||||
private Panel BuildExportPage()
|
||||
{
|
||||
var stack = NewStack();
|
||||
@@ -283,21 +640,6 @@ internal sealed class SettingsPanel : Panel
|
||||
stack.Add(Slider("Intervallo fra fotogrammi chiave", 1, 10, _project.Export.KeyframeIntervalSeconds, 1, "0", "s",
|
||||
value => _project.Export.KeyframeIntervalSeconds = (int)value));
|
||||
|
||||
stack.Add(new SectionHeader("Andamento temporale"));
|
||||
stack.Add(Combo("Durata dei fotogrammi",
|
||||
["Costante — un fotogramma per scatto",
|
||||
"Adattiva — durata proporzionale all'intervallo",
|
||||
"Interpolata — cadenza uniformata con fotogrammi sintetici"],
|
||||
(int)_project.Export.Timing,
|
||||
index =>
|
||||
{
|
||||
_project.Export.Timing = (FrameTimingMode)index;
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Dilatazione massima", 1.5, 8, _project.Export.MaxAdaptiveStretch, 0.5, "0.0", "×",
|
||||
value => _project.Export.MaxAdaptiveStretch = value));
|
||||
|
||||
stack.Add(new SectionHeader("Codifica"));
|
||||
stack.Add(Check("Preferisci l'encoder hardware", _project.Export.PreferHardware,
|
||||
value => _project.Export.PreferHardware = value));
|
||||
@@ -309,8 +651,9 @@ internal sealed class SettingsPanel : Panel
|
||||
browse.Click += (_, _) => BrowseOutputRequested?.Invoke(this, EventArgs.Empty);
|
||||
stack.Add(browse);
|
||||
|
||||
stack.Add(Note("Il video viene scritto in un unico flusso continuo: l'elaborazione non " +
|
||||
"genera alcun file temporaneo su disco."));
|
||||
stack.Add(Note("Il video viene scritto in un unico flusso continuo. L'unico altro file che " +
|
||||
"l'elaborazione può creare è il parcheggio temporaneo dei fotogrammi, che si " +
|
||||
"cancella da sé e non contiene nulla di riutilizzabile."));
|
||||
|
||||
return stack.Panel;
|
||||
}
|
||||
@@ -332,18 +675,36 @@ internal sealed class SettingsPanel : Panel
|
||||
|
||||
// Le note esplicative variano in lunghezza: si misurano sulla larghezza reale
|
||||
// della colonna, altrimenti le più lunghe finirebbero tagliate a metà frase.
|
||||
if (control is Label note && !note.AutoSize)
|
||||
{
|
||||
var measured = TextRenderer.MeasureText(note.Text, note.Font,
|
||||
new Size(control.Width, 0), TextFormatFlags.WordBreak);
|
||||
control.Height = measured.Height + 8;
|
||||
}
|
||||
if (control is Label note && !note.AutoSize) MeasureNote(note);
|
||||
|
||||
Panel.Controls.Add(control);
|
||||
_y += control.Height + 6;
|
||||
}
|
||||
}
|
||||
|
||||
private static void MeasureNote(Label note)
|
||||
{
|
||||
var measured = TextRenderer.MeasureText(note.Text, note.Font,
|
||||
new Size(Math.Max(40, note.Width), 0),
|
||||
TextFormatFlags.WordBreak);
|
||||
note.Height = measured.Height + 8;
|
||||
}
|
||||
|
||||
/// <summary>Rimisura una nota il cui testo è cambiato dopo la costruzione della pagina.</summary>
|
||||
private static void LayoutNote(Label note)
|
||||
{
|
||||
int before = note.Height;
|
||||
MeasureNote(note);
|
||||
if (note.Height == before || note.Parent is null) return;
|
||||
|
||||
// Le note sotto vanno fatte scorrere: la colonna è posizionata a coordinate assolute.
|
||||
int delta = note.Height - before;
|
||||
foreach (Control sibling in note.Parent.Controls)
|
||||
{
|
||||
if (sibling != note && sibling.Top > note.Top) sibling.Top += delta;
|
||||
}
|
||||
}
|
||||
|
||||
private static Stack NewStack()
|
||||
{
|
||||
var panel = new Panel
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
using Titano.Core;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Editor grafico di una curva a nodi: si trascinano i punti, si aggiungono con un doppio
|
||||
/// clic e si tolgono con il tasto destro. La curva disegnata è la stessa che il motore
|
||||
/// valuta, non un'approssimazione: quello che si vede è quello che verrà eseguito.
|
||||
///
|
||||
/// L'asse verticale è logaritmico perché il valore rappresenta una velocità, e per una
|
||||
/// velocità il contrario di "doppio" è "metà", non "meno uno": su una scala lineare metà
|
||||
/// dello spazio finirebbe fra 1× e 8× e l'altra metà schiacciata fra 0,1× e 1×, rendendo i
|
||||
/// rallentamenti impossibili da regolare.
|
||||
/// </summary>
|
||||
internal sealed class SplineEditor : Control
|
||||
{
|
||||
private const int Gutter = 38;
|
||||
private const float HitRadius = 9f;
|
||||
|
||||
private List<SplineKnot> _knots = [new(0, 1), new(0.5, 1), new(1, 1)];
|
||||
private int _dragging = -1;
|
||||
private int _hovered = -1;
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
public double Minimum { get; set; } = 0.1;
|
||||
public double Maximum { get; set; } = 8.0;
|
||||
public string UnitFormat { get; set; } = "0.##×";
|
||||
|
||||
/// <summary>Etichette dei due estremi dell'asse orizzontale.</summary>
|
||||
public string StartLabel { get; set; } = "inizio";
|
||||
public string EndLabel { get; set; } = "fine";
|
||||
|
||||
public SplineEditor()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Surface;
|
||||
Height = 168;
|
||||
Cursor = Cursors.Hand;
|
||||
}
|
||||
|
||||
public IReadOnlyList<SplineKnot> Knots => _knots;
|
||||
|
||||
public void SetKnots(IEnumerable<SplineKnot> knots)
|
||||
{
|
||||
_knots = [.. knots.OrderBy(k => k.X)];
|
||||
if (_knots.Count < 2) _knots = [new(0, 1), new(1, 1)];
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ interazione
|
||||
|
||||
private Rectangle Plot => new(Gutter, 12, Math.Max(20, Width - Gutter - 14),
|
||||
Math.Max(20, Height - 12 - 24));
|
||||
|
||||
private PointF ToScreen(SplineKnot knot)
|
||||
{
|
||||
var plot = Plot;
|
||||
double logMin = Math.Log(Minimum), logMax = Math.Log(Maximum);
|
||||
double normalized = (Math.Log(Math.Clamp(knot.Y, Minimum, Maximum)) - logMin) / (logMax - logMin);
|
||||
return new PointF(plot.Left + (float)(knot.X * plot.Width),
|
||||
plot.Bottom - (float)(normalized * plot.Height));
|
||||
}
|
||||
|
||||
private SplineKnot ToValue(float x, float y)
|
||||
{
|
||||
var plot = Plot;
|
||||
double logMin = Math.Log(Minimum), logMax = Math.Log(Maximum);
|
||||
double fx = Math.Clamp((x - plot.Left) / (double)plot.Width, 0, 1);
|
||||
double fy = Math.Clamp((plot.Bottom - y) / (double)plot.Height, 0, 1);
|
||||
return new SplineKnot(fx, Math.Exp(logMin + fy * (logMax - logMin)));
|
||||
}
|
||||
|
||||
private int HitTest(Point location)
|
||||
{
|
||||
for (int i = 0; i < _knots.Count; i++)
|
||||
{
|
||||
var point = ToScreen(_knots[i]);
|
||||
float dx = point.X - location.X;
|
||||
float dy = point.Y - location.Y;
|
||||
if (dx * dx + dy * dy <= HitRadius * HitRadius) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
Focus();
|
||||
int index = HitTest(e.Location);
|
||||
|
||||
if (e.Button == MouseButtons.Right)
|
||||
{
|
||||
// Gli estremi non si tolgono: senza di loro la curva non coprirebbe la sequenza.
|
||||
if (index > 0 && index < _knots.Count - 1)
|
||||
{
|
||||
_knots.RemoveAt(index);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Button == MouseButtons.Left) _dragging = index;
|
||||
base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseDoubleClick(MouseEventArgs e)
|
||||
{
|
||||
if (e.Button != MouseButtons.Left || HitTest(e.Location) >= 0) return;
|
||||
|
||||
var value = ToValue(e.X, e.Y);
|
||||
_knots.Add(value);
|
||||
_knots = [.. _knots.OrderBy(k => k.X)];
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
if (_dragging >= 0 && _dragging < _knots.Count)
|
||||
{
|
||||
var value = ToValue(e.X, e.Y);
|
||||
|
||||
// Gli estremi restano ancorati; gli altri non possono scavalcare i vicini,
|
||||
// altrimenti l'ordine dei nodi — su cui si regge la monotonia — salterebbe.
|
||||
double x = _dragging == 0 ? 0
|
||||
: _dragging == _knots.Count - 1 ? 1
|
||||
: Math.Clamp(value.X, _knots[_dragging - 1].X + 0.01, _knots[_dragging + 1].X - 0.01);
|
||||
|
||||
_knots[_dragging] = new SplineKnot(x, value.Y);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
int hovered = HitTest(e.Location);
|
||||
if (hovered != _hovered) { _hovered = hovered; Invalidate(); }
|
||||
base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
_dragging = -1;
|
||||
base.OnMouseUp(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
_hovered = -1;
|
||||
Invalidate();
|
||||
base.OnMouseLeave(e);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ disegno
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Parent?.BackColor ?? Theme.Surface);
|
||||
|
||||
var plot = Plot;
|
||||
Theme.FillRounded(g, plot, 4f, Theme.SurfaceAlt);
|
||||
|
||||
using (var gridPen = new Pen(Theme.Border) { DashStyle = DashStyle.Dot })
|
||||
using (var unityPen = new Pen(Color.FromArgb(150, Theme.TextFaint)))
|
||||
{
|
||||
foreach (double value in (ReadOnlySpan<double>)[0.25, 0.5, 1, 2, 4])
|
||||
{
|
||||
if (value < Minimum || value > Maximum) continue;
|
||||
var point = ToScreen(new SplineKnot(0, value));
|
||||
bool unity = Math.Abs(value - 1) < 1e-9;
|
||||
g.DrawLine(unity ? unityPen : gridPen, plot.Left, point.Y, plot.Right, point.Y);
|
||||
|
||||
TextRenderer.DrawText(g, value.ToString(UnitFormat), Theme.Small,
|
||||
new Rectangle(0, (int)point.Y - 8, Gutter - 4, 16),
|
||||
unity ? Theme.TextMuted : Theme.TextFaint,
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
|
||||
for (int i = 1; i < 4; i++)
|
||||
{
|
||||
float x = plot.Left + plot.Width * i / 4f;
|
||||
g.DrawLine(gridPen, x, plot.Top, x, plot.Bottom);
|
||||
}
|
||||
}
|
||||
|
||||
// Curva: un campione per pixel, valutata esattamente come la valuta il motore.
|
||||
var points = new PointF[Math.Max(2, plot.Width)];
|
||||
for (int i = 0; i < points.Length; i++)
|
||||
{
|
||||
double x = i / (double)(points.Length - 1);
|
||||
double y = Math.Clamp(Spline.Evaluate(_knots, x), Minimum, Maximum);
|
||||
points[i] = ToScreen(new SplineKnot(x, y));
|
||||
}
|
||||
|
||||
using (var area = new GraphicsPath())
|
||||
{
|
||||
var polygon = new PointF[points.Length + 2];
|
||||
Array.Copy(points, polygon, points.Length);
|
||||
polygon[^2] = new PointF(plot.Right, plot.Bottom);
|
||||
polygon[^1] = new PointF(plot.Left, plot.Bottom);
|
||||
area.AddPolygon(polygon);
|
||||
|
||||
using var fill = new SolidBrush(Color.FromArgb(34, Theme.Accent));
|
||||
var clip = g.Clip;
|
||||
g.SetClip(plot);
|
||||
g.FillPath(fill, area);
|
||||
g.Clip = clip;
|
||||
}
|
||||
|
||||
using (var pen = new Pen(Theme.Accent, 2f) { LineJoin = LineJoin.Round })
|
||||
{
|
||||
g.DrawLines(pen, points);
|
||||
}
|
||||
|
||||
for (int i = 0; i < _knots.Count; i++)
|
||||
{
|
||||
var point = ToScreen(_knots[i]);
|
||||
float radius = i == _dragging ? 6.5f : i == _hovered ? 6f : 5f;
|
||||
|
||||
using var fill = new SolidBrush(Theme.Text);
|
||||
using var ring = new Pen(Theme.Accent, 2f);
|
||||
g.FillEllipse(fill, point.X - radius, point.Y - radius, radius * 2, radius * 2);
|
||||
g.DrawEllipse(ring, point.X - radius, point.Y - radius, radius * 2, radius * 2);
|
||||
}
|
||||
|
||||
// Le tre etichette condividono una sola riga: quelle laterali sono corte per scelta e
|
||||
// al centro resta lo spazio per il valore del nodo sotto il puntatore.
|
||||
int labelY = plot.Bottom + 4;
|
||||
var startSize = TextRenderer.MeasureText(g, StartLabel, Theme.Small);
|
||||
var endSize = TextRenderer.MeasureText(g, EndLabel, Theme.Small);
|
||||
|
||||
TextRenderer.DrawText(g, StartLabel, Theme.Small,
|
||||
new Rectangle(plot.Left, labelY, startSize.Width + 4, 16),
|
||||
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
TextRenderer.DrawText(g, EndLabel, Theme.Small,
|
||||
new Rectangle(plot.Right - endSize.Width - 4, labelY, endSize.Width + 4, 16),
|
||||
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
||||
|
||||
if (_hovered >= 0 && _hovered < _knots.Count)
|
||||
{
|
||||
int left = plot.Left + startSize.Width + 10;
|
||||
int right = plot.Right - endSize.Width - 10;
|
||||
TextRenderer.DrawText(g, _knots[_hovered].Y.ToString(UnitFormat), Theme.SmallBold,
|
||||
new Rectangle(left, labelY, Math.Max(20, right - left), 16), Theme.Text,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,12 @@ internal static class Theme
|
||||
public static readonly Color Accent = Color.FromArgb(0x4C, 0x9A, 0xFF);
|
||||
public static readonly Color AccentDim = Color.FromArgb(0x2F, 0x6C, 0xC2);
|
||||
public static readonly Color Measured = Color.FromArgb(0xF5, 0xA5, 0x24);
|
||||
|
||||
// Le due regioni hanno colori propri, scelti lontani dall'ambra del misurato e dal blu
|
||||
// del target: sul grafico compaiono insieme a quelle, e due curve dello stesso colore
|
||||
// sarebbero peggio che non disegnarle.
|
||||
public static readonly Color RegionHigh = Color.FromArgb(0x5A, 0xD1, 0xC8);
|
||||
public static readonly Color RegionLow = Color.FromArgb(0xC0, 0x84, 0x57);
|
||||
public static readonly Color Success = Color.FromArgb(0x35, 0xC4, 0x8F);
|
||||
public static readonly Color Warning = Color.FromArgb(0xE8, 0xB3, 0x39);
|
||||
public static readonly Color Danger = Color.FromArgb(0xF0, 0x57, 0x5A);
|
||||
|
||||
Reference in New Issue
Block a user