Rifa' l'impaginazione attorno a una barra verticale sul fianco sinistro che governa insieme il contenuto principale e la colonna delle impostazioni: una sezione, una vista, i suoi comandi. Prima c'erano due gerarchie di schede da tenere allineate a mano; adesso ce n'e' una sola, in verticale ci sta il nome per esteso, e la sezione scelta resta leggibile mentre si lavora - cosa che in una fila di schede in cima si perde appena l'occhio scende sul contenuto. Accanto a ogni voce compare il numero di avvisi che la riguardano. L'anteprima non e' piu' il contenuto di una scheda fra le altre: resta in alto sempre, perche' in un programma che tratta immagini l'immagine si guarda mentre si regola qualunque cosa. Guadagna le quattro cose che la rendono utile per giudicare e non solo per guardare. Zoom e trascinamento, perche' una stabilizzazione sotto il pixel non si vede su un'immagine rimpicciolita per stare in un riquadro, e da scala uno a uno in su i pixel si mostrano come sono invece di essere interpolati. Confronto a tendina fra originale e corretto, perche' l'unico modo di capire cosa una correzione stia facendo e' vedere accanto ciо' che c'era prima. Campo vettoriale sovrapposto, che il motore calcola comunque e che spiega perche' la sfocatura viene come viene, soprattutto quando e' sbagliata. Confine delle regioni, campionato attraverso la stessa mappatura del ritaglio cosi' resta al suo posto anche sotto una panoramica virtuale. Ogni sezione porta lo strumento di misura che le riguarda: striscia di provini e pannello degli avvisi sulla sequenza, istogramma e forma d'onda sull'esposizione, percorso ricostruito della stabilizzazione sul movimento, piano temporale sul tempo. Sono tutte grandezze che il motore gia' calcolava e che finivano in due numeri in fondo a una riga di stato, cioe' invisibili. Il pilota automatico. I parametri deducibili dalle misure non si chiedono piu': un cursore con l'indicatore auto mostra il valore scelto e il motivo, toccarlo passa il comando all'utente, l'indicatore lo restituisce. E' lo stesso modello che il menu dell'orientamento usava da solo, esteso a larghezza di analisi, finestra del deflicker, lunghezza della transizione, finestra della stabilizzazione e tetto di memoria. Non sono valori di comodo: la finestra del deflicker viene da quattro periodi dello sfarfallio misurati sull'autocorrelazione, quella della stabilizzazione e' la piu' corta che rende liscio il percorso, la transizione e' meta' della distanza tipica fra i cambi. Dove il valore giusto non si puo' misurare il direttore non inventa: restituisce meno decisioni e lascia il cursore dov'e'. Il riquadro sponsor sta soltanto nella scheda Esportazione, perche' quello e' l'unico momento in cui non c'e' niente da fare e uno spazio pubblicitario non toglie niente a nessuno; accanto a un cursore che si sta regolando sarebbe un ostacolo. Gli annunci si leggono da una cartella locale con un listino in formato testo, ed e' importante dire cosa NON fa: nessuna rete, nessun identificativo, nessun clic registrato. Non e' prudenza eccessiva - un circuito pubblicitario vero richiederebbe il suo SDK, che il vincolo sulle dipendenze esclude, e comunque significherebbe far uscire dati dalla macchina di chi sta montando un time-lapse. Le campagne si aggiornano copiando file; a listino vuoto compaiono note interne; il riquadro si spegne dalle preferenze. Aggiunge anche le preferenze dell'applicazione, distinte da quelle del progetto, con persistenza in un file di testo scritto a mano nello stesso spirito del resto: una riga per voce, correggibile con un editor. Il progetto descrive come trattare questi fotogrammi, le preferenze come si comporta il programma. Piu' due cose piccole che pesavano: immissione numerica sui cursori, perche' trascinare fino a 0,35 e' un esercizio di mira e non una regolazione, e anteprima dei fotogrammi mentre vengono codificati, che non migliora il risultato di un pixel ma cambia molto un'attesa di mezz'ora. Verifica: 54 controlli invariati, Debug e Release puliti, tutte e sei le sezioni ispezionate a video sulla scena di prova. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
420 lines
16 KiB
C#
420 lines
16 KiB
C#
using System.Diagnostics;
|
|
using System.Text;
|
|
using Titano.Pipeline;
|
|
|
|
namespace Titano.UI;
|
|
|
|
/// <summary>Un annuncio del listino locale.</summary>
|
|
internal sealed record Sponsor(string Title, string Body, string? ImagePath, string? Link, int Weight)
|
|
{
|
|
public bool HasLink => !string.IsNullOrWhiteSpace(Link);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Listino degli sponsor, letto da una cartella locale.
|
|
///
|
|
/// Nessuna rete: il programma non contatta alcun servizio, non invia identificativi e non
|
|
/// registra clic. È una scelta, non una mancanza — un circuito pubblicitario vero
|
|
/// richiederebbe di integrarne l'SDK, che il vincolo sulle dipendenze esclude, e comunque
|
|
/// significherebbe far uscire dati dalla macchina di chi sta soltanto montando un
|
|
/// time-lapse. Le campagne si aggiornano copiando file in una cartella.
|
|
///
|
|
/// Il formato è lo stesso delle impostazioni: blocchi introdotti da [campagna], una riga per
|
|
/// campo. Le immagini stanno accanto al listino.
|
|
/// </summary>
|
|
internal static class SponsorCatalogue
|
|
{
|
|
public const string ManifestName = "campagne.txt";
|
|
|
|
public static List<Sponsor> Load(string folder)
|
|
{
|
|
var sponsors = new List<Sponsor>();
|
|
try
|
|
{
|
|
string manifest = Path.Combine(folder, ManifestName);
|
|
if (!File.Exists(manifest)) return sponsors;
|
|
|
|
string? title = null, body = null, image = null, link = null;
|
|
int weight = 1;
|
|
|
|
void Flush()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(title)) return;
|
|
string? resolved = string.IsNullOrWhiteSpace(image) ? null : Path.Combine(folder, image);
|
|
if (resolved is not null && !File.Exists(resolved)) resolved = null;
|
|
sponsors.Add(new Sponsor(title!, body ?? string.Empty, resolved, Sanitize(link), Math.Clamp(weight, 1, 10)));
|
|
}
|
|
|
|
foreach (string raw in File.ReadAllLines(manifest, Encoding.UTF8))
|
|
{
|
|
string line = raw.Trim();
|
|
if (line.Length == 0 || line[0] == '#') continue;
|
|
|
|
if (line.StartsWith('[') && line.EndsWith(']'))
|
|
{
|
|
Flush();
|
|
title = body = image = link = null;
|
|
weight = 1;
|
|
continue;
|
|
}
|
|
|
|
int separator = line.IndexOf('=');
|
|
if (separator <= 0) continue;
|
|
string key = line[..separator].Trim().ToLowerInvariant();
|
|
string value = line[(separator + 1)..].Trim();
|
|
|
|
switch (key)
|
|
{
|
|
case "titolo": title = value; break;
|
|
case "testo": body = value; break;
|
|
case "immagine": image = value; break;
|
|
case "collegamento": link = value; break;
|
|
case "peso": weight = AppSettings.ParseInt(value, 1); break;
|
|
}
|
|
}
|
|
Flush();
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
|
|
{
|
|
// Listino illeggibile: restano gli annunci interni, che non mancano mai.
|
|
}
|
|
|
|
return sponsors;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Crea la cartella e, se è vuota, vi scrive un listino di esempio commentato. Il formato
|
|
/// si impara guardando un file che c'è già, non cercando dove sia documentato.
|
|
/// </summary>
|
|
public static void EnsureExample(string folder)
|
|
{
|
|
try
|
|
{
|
|
Directory.CreateDirectory(folder);
|
|
string manifest = Path.Combine(folder, ManifestName);
|
|
if (File.Exists(manifest)) return;
|
|
|
|
File.WriteAllText(manifest, """
|
|
# Listino delle campagne mostrate durante l'esportazione.
|
|
#
|
|
# Un blocco per annuncio, introdotto da [campagna]. Le immagini stanno in
|
|
# questa stessa cartella; sono facoltative, e vengono ridimensionate
|
|
# conservando le proporzioni. Il collegamento deve essere http o https:
|
|
# qualunque altra cosa viene ignorata.
|
|
#
|
|
# Il peso governa la frequenza: peso 3 compare tre volte più spesso di peso 1.
|
|
#
|
|
# Titano non contatta nessun servizio e non registra i clic: questo file è
|
|
# l'unica sorgente degli annunci.
|
|
|
|
[campagna]
|
|
titolo = Titolo dell'annuncio
|
|
testo = Una o due righe di testo. Chi legge sta aspettando la fine di una codifica, quindi può leggere una frase intera.
|
|
immagine = esempio.jpg
|
|
collegamento = https://esempio.it
|
|
peso = 1
|
|
""", Encoding.UTF8);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
{
|
|
// Cartella non scrivibile: restano gli annunci interni.
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Accetta solo indirizzi web. Il listino è un file locale, ma aprire alla cieca ciò che
|
|
/// vi si trova scritto significherebbe eseguire quello che qualcuno vi ha messo dentro.
|
|
/// </summary>
|
|
private static string? Sanitize(string? link)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(link)) return null;
|
|
return Uri.TryCreate(link, UriKind.Absolute, out var uri) &&
|
|
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
|
|
? uri.AbsoluteUri
|
|
: null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Contenuti interni, mostrati quando il listino è vuoto. Non sono un riempitivo: sono
|
|
/// le cose che un'attesa di mezz'ora è il momento giusto per leggere.
|
|
/// </summary>
|
|
public static List<Sponsor> House() =>
|
|
[
|
|
new("Il profilo di qualità governa il tempo",
|
|
"Massima infittisce il campo vettoriale e i campioni della scia. Su una sequenza senza " +
|
|
"movimento, Standard dà lo stesso risultato in un terzo del tempo.", null, null, 1),
|
|
new("La finestra della mediana vive in memoria",
|
|
"I fotogrammi della finestra sono vivi tutti insieme, perché la mediana li vuole " +
|
|
"simultaneamente. Il tetto di memoria governa la lettura in anticipo, non la finestra.", null, null, 1),
|
|
new("Un cielo più scuro del terreno è normale",
|
|
"Di notte, con un primo piano illuminato, la regione superiore è la più scura. " +
|
|
"La divisione per orizzonte se ne accorge e non la scambia per un errore.", null, null, 1),
|
|
new("I cambi di esposizione invisibili restano tali",
|
|
"Se il fotogramma è già saturo, cambiare sensibilità non lo scurisce. Titano lo verifica " +
|
|
"prima di correggere: togliere un gradino che non c'è significa introdurlo.", null, null, 1),
|
|
];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Riquadro degli sponsor, mostrato durante l'attesa dell'esportazione.
|
|
///
|
|
/// Sta soltanto qui, nella scheda dove per forza di cose si aspetta, e non compare mai
|
|
/// altrove: un annuncio accanto a un cursore che si sta regolando sarebbe un ostacolo, uno
|
|
/// accanto a una barra di avanzamento è qualcosa da guardare mentre passa il tempo. È
|
|
/// dichiarato come tale, non si muove da solo se il sistema chiede di ridurre le animazioni,
|
|
/// e si spegne dalle impostazioni.
|
|
/// </summary>
|
|
internal sealed class SponsorPanel : Control
|
|
{
|
|
private readonly System.Windows.Forms.Timer _rotation = new();
|
|
private List<Sponsor> _sponsors = [];
|
|
private readonly List<int> _order = [];
|
|
private int _position;
|
|
private Image? _image;
|
|
private string? _imagePath;
|
|
private bool _hoverLink;
|
|
private bool _hoverNext;
|
|
|
|
public SponsorPanel()
|
|
{
|
|
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
|
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
|
BackColor = Theme.Surface;
|
|
Height = 132;
|
|
|
|
_rotation.Tick += (_, _) => Advance();
|
|
}
|
|
|
|
public Sponsor? Current => _order.Count > 0 && _position < _order.Count
|
|
? _sponsors[_order[_position]]
|
|
: null;
|
|
|
|
/// <summary>Carica il listino e avvia la rotazione secondo le preferenze.</summary>
|
|
public void Configure(AppSettings settings)
|
|
{
|
|
Visible = settings.ShowSponsors;
|
|
if (!settings.ShowSponsors)
|
|
{
|
|
_rotation.Stop();
|
|
return;
|
|
}
|
|
|
|
var loaded = SponsorCatalogue.Load(settings.ResolvedSponsorFolder);
|
|
_sponsors = loaded.Count > 0 ? loaded : SponsorCatalogue.House();
|
|
|
|
// L'ordine tiene conto del peso ripetendo le voci: una campagna di peso tre compare
|
|
// tre volte nel giro, senza bisogno di estrazioni casuali che potrebbero saltarla.
|
|
_order.Clear();
|
|
for (int i = 0; i < _sponsors.Count; i++)
|
|
{
|
|
for (int repeat = 0; repeat < _sponsors[i].Weight; repeat++) _order.Add(i);
|
|
}
|
|
Shuffle(_order);
|
|
|
|
_position = 0;
|
|
LoadImage();
|
|
|
|
_rotation.Interval = Math.Clamp(settings.SponsorRotationSeconds, 5, 600) * 1000;
|
|
_rotation.Start();
|
|
Invalidate();
|
|
}
|
|
|
|
/// <summary>Ferma la rotazione quando la scheda non è in vista: non serve a nessuno girare a vuoto.</summary>
|
|
public void SetActive(bool active)
|
|
{
|
|
if (!Visible) return;
|
|
if (active) _rotation.Start(); else _rotation.Stop();
|
|
}
|
|
|
|
private static void Shuffle(List<int> values)
|
|
{
|
|
for (int i = values.Count - 1; i > 0; i--)
|
|
{
|
|
int j = Random.Shared.Next(i + 1);
|
|
(values[i], values[j]) = (values[j], values[i]);
|
|
}
|
|
}
|
|
|
|
private void Advance()
|
|
{
|
|
if (_order.Count == 0) return;
|
|
_position = (_position + 1) % _order.Count;
|
|
LoadImage();
|
|
Invalidate();
|
|
}
|
|
|
|
private void LoadImage()
|
|
{
|
|
string? path = Current?.ImagePath;
|
|
if (path == _imagePath) return;
|
|
|
|
_image?.Dispose();
|
|
_image = null;
|
|
_imagePath = path;
|
|
|
|
if (path is null) return;
|
|
try
|
|
{
|
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
|
|
_image = Image.FromStream(stream);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or ArgumentException or OutOfMemoryException)
|
|
{
|
|
_image = null;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ interazione
|
|
|
|
private Rectangle NextButton => new(Width - 26, 6, 18, 18);
|
|
|
|
private Rectangle TextArea
|
|
{
|
|
get
|
|
{
|
|
int left = _image is null ? 14 : 14 + ThumbWidth + 14;
|
|
return new Rectangle(left, 26, Math.Max(40, Width - left - 16), Math.Max(20, Height - 36));
|
|
}
|
|
}
|
|
|
|
private int ThumbWidth => Math.Min(168, Math.Max(80, (Height - 28) * 16 / 9));
|
|
|
|
protected override void OnMouseMove(MouseEventArgs e)
|
|
{
|
|
bool overNext = NextButton.Contains(e.Location);
|
|
bool overLink = Current is { HasLink: true } && TextArea.Contains(e.Location);
|
|
|
|
if (overNext != _hoverNext || overLink != _hoverLink)
|
|
{
|
|
_hoverNext = overNext;
|
|
_hoverLink = overLink;
|
|
Cursor = overNext || overLink ? Cursors.Hand : Cursors.Default;
|
|
Invalidate();
|
|
}
|
|
base.OnMouseMove(e);
|
|
}
|
|
|
|
protected override void OnMouseLeave(EventArgs e)
|
|
{
|
|
_hoverNext = _hoverLink = false;
|
|
Cursor = Cursors.Default;
|
|
Invalidate();
|
|
base.OnMouseLeave(e);
|
|
}
|
|
|
|
protected override void OnMouseDown(MouseEventArgs e)
|
|
{
|
|
if (e.Button != MouseButtons.Left) return;
|
|
|
|
if (NextButton.Contains(e.Location)) { Advance(); return; }
|
|
|
|
if (Current is { HasLink: true, Link: { } link } && TextArea.Contains(e.Location))
|
|
{
|
|
try
|
|
{
|
|
Process.Start(new ProcessStartInfo(link) { UseShellExecute = true });
|
|
}
|
|
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or IOException)
|
|
{
|
|
// Nessun browser predefinito: non c'è nulla di sensato da fare.
|
|
}
|
|
}
|
|
base.OnMouseDown(e);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ disegno
|
|
|
|
protected override void OnPaint(PaintEventArgs e)
|
|
{
|
|
var g = e.Graphics;
|
|
Theme.HighQuality(g);
|
|
g.Clear(Parent?.BackColor ?? Theme.Background);
|
|
|
|
var bounds = new RectangleF(0.5f, 0.5f, Width - 1, Height - 1);
|
|
Theme.FillAndStroke(g, bounds, 6f, Theme.Surface, Theme.Border);
|
|
|
|
if (Current is not { } sponsor)
|
|
{
|
|
TextRenderer.DrawText(g, "Nessuna campagna nel listino", Theme.Small,
|
|
new Rectangle(0, 0, Width, Height), Theme.TextFaint,
|
|
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
|
return;
|
|
}
|
|
|
|
// L'etichetta è la prima cosa che si legge: chi guarda deve sapere subito che cos'è.
|
|
TextRenderer.DrawText(g, "SPONSOR", Theme.SmallBold, new Rectangle(14, 5, 120, 16),
|
|
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
|
|
|
if (_order.Count > 1) DrawNextButton(g);
|
|
|
|
if (_image is not null)
|
|
{
|
|
var box = new Rectangle(14, 26, ThumbWidth, Height - 40);
|
|
DrawImageFitted(g, _image, box);
|
|
}
|
|
|
|
var text = TextArea;
|
|
TextRenderer.DrawText(g, sponsor.Title, Theme.BodyBold,
|
|
new Rectangle(text.Left, text.Top, text.Width, 20),
|
|
_hoverLink ? Theme.Accent : Theme.Text,
|
|
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
|
|
|
TextRenderer.DrawText(g, sponsor.Body, Theme.Small,
|
|
new Rectangle(text.Left, text.Top + 22, text.Width, text.Height - 40),
|
|
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
|
|
|
|
if (sponsor.HasLink)
|
|
{
|
|
TextRenderer.DrawText(g, _hoverLink ? "Apri nel browser →" : "Clic per saperne di più",
|
|
Theme.Small, new Rectangle(text.Left, Height - 20, text.Width, 16),
|
|
_hoverLink ? Theme.Accent : Theme.TextFaint,
|
|
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
|
}
|
|
}
|
|
|
|
private void DrawNextButton(Graphics g)
|
|
{
|
|
var box = NextButton;
|
|
if (_hoverNext) Theme.FillRounded(g, box, 4f, Theme.SurfaceAlt);
|
|
|
|
using var pen = new Pen(_hoverNext ? Theme.Text : Theme.TextFaint, 1.5f)
|
|
{
|
|
StartCap = System.Drawing.Drawing2D.LineCap.Round,
|
|
EndCap = System.Drawing.Drawing2D.LineCap.Round,
|
|
};
|
|
float cx = box.Left + box.Width / 2f;
|
|
float cy = box.Top + box.Height / 2f;
|
|
g.DrawLines(pen,
|
|
[
|
|
new PointF(cx - 2.5f, cy - 4f),
|
|
new PointF(cx + 2.5f, cy),
|
|
new PointF(cx - 2.5f, cy + 4f),
|
|
]);
|
|
}
|
|
|
|
/// <summary>Disegna l'immagine dentro il riquadro conservandone le proporzioni.</summary>
|
|
private static void DrawImageFitted(Graphics g, Image image, Rectangle box)
|
|
{
|
|
double scale = Math.Min(box.Width / (double)image.Width, box.Height / (double)image.Height);
|
|
int width = Math.Max(1, (int)(image.Width * scale));
|
|
int height = Math.Max(1, (int)(image.Height * scale));
|
|
var target = new Rectangle(box.Left + (box.Width - width) / 2,
|
|
box.Top + (box.Height - height) / 2, width, height);
|
|
|
|
g.DrawImage(image, target);
|
|
using var border = new Pen(Theme.Border);
|
|
g.DrawRectangle(border, target);
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
_rotation.Stop();
|
|
_rotation.Dispose();
|
|
_image?.Dispose();
|
|
}
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|