Scheda Archivio, ottimizzazione per sezione e spiegazioni a comparsa

Aggiunge l'importazione da scheda di memoria e riordina l'interfaccia attorno
a quello che ciascuna sezione serve a fare.

L'importazione riconosce i supporti collegati, cerca nelle sottocartelle e
prima di toccare il disco mostra il piano: quali cartelle nasceranno e che nome
avranno i primi file. Un'importazione sbagliata su mille file non si annulla, e
guardare prima costa un istante. Fa poi tre cose che a mano si sbagliano
sempre: separa le sessioni sulla pausa fra due scatti, invece di lasciare piu'
riprese mescolate in una cartella sola; costruisce i nomi da modelli con
segnaposto, perche' un archivio si consulta anni dopo e il nome e' l'unica cosa
leggibile senza aprire nulla; e rilegge quello che ha scritto confrontandone
l'impronta, perche' una scheda che si scollega a meta' copia produce file della
dimensione giusta e del contenuto troncato, e il danno si scopre mesi dopo.

Sulla conversione in DNG va detto con precisione cosa si puo' e non si puo'
fare. Un DNG vero contiene i valori del sensore prima dell'interpolazione
cromatica; ottenerlo da un formato proprietario richiede la libreria del
produttore, che il vincolo sulle dipendenze esclude. Quello che si ottiene
in-house e' un DNG lineare - la specifica lo prevede, i pixel sono gia'
interpolati, il file e' valido e apribile ovunque ma non restituisce la
liberta' del grezzo. La copia resta quindi la scelta predefinita, e l'interfaccia
lo dice invece di lasciarlo intuire. Il contenitore TIFF/DNG e' scritto a mano
come il multiplexer MP4, e la verifica lo rilegge con il parser di questo stesso
programma: due implementazioni indipendenti dello stesso formato, e al primo
confronto e' saltato fuori un errore di dodici byte per voce nel calcolo degli
scostamenti - la directory Exif finiva oltre il puntatore che la indicava.

Le spiegazioni passano dalle note stampate ai suggerimenti a comparsa. Una nota
sotto un cursore occupa spazio a chi la conosce gia', quindi deve restare corta;
un suggerimento che appare solo quando serve non ha quel vincolo e puo' dire
l'unica cosa che conta - perche' quel parametro esiste e cosa succede a
spostarlo nel verso sbagliato.

Ogni sezione guadagna un comando Ottimizza che rileva le impostazioni migliori
per quella sola parte e riferisce cosa ha cambiato e perche'. Si distingue dal
pilota automatico, che lavora di continuo sui parametri deducibili senza
ambiguita': l'ottimizzazione si chiede a mano perche' accende e spegne interi
moduli, e sostituire quelle scelte in silenzio sarebbe peggio che lasciarle
sbagliate. Dove il dato manca non tira a indovinare: lo dice.

L'uscita guadagna rapporto e ritaglio. Cambiare rapporto non deforma piu'
l'immagine: il ritaglio viene preso con il nuovo rapporto dentro il fotogramma,
il che ha anche corretto un difetto latente dello stadio geometrico, che con
rapporti diversi fra sorgente e uscita stirava invece di tagliare. La zona da
tenere si sceglie trascinandola.

I comandi seguono la sezione: importazione e analisi compaiono solo dove
servono. Un pulsante che non ha senso dove ci si trova non va disabilitato ma
tolto, perche' disabilitato resta un ingombro che chiede perche' non funziona.
Le preferenze dell'applicazione non mostrano piu' anteprima, riepilogo della
sequenza ne' riga di stato che ne parli: non riguardano la sequenza caricata, e
tenerle accanto confondeva due piani diversi.

Via anche le descrizioni inutili: la finestra si chiama Titano e basta.

Due difetti trovati durante la verifica a video. La barra di navigazione nasceva
sulla prima voce, che ora e' Archivio, quindi il selettore usciva subito perche'
l'indice coincideva e la sezione non veniva mai mostrata: barra su una voce,
contenuto su un'altra. E il posizionamento dei pulsanti leggeva Visible, che in
WinForms resta falso finche' la finestra non e' stata mostrata perche' riporta
la visibilita' dell'intera catena: alla costruzione nessun pulsante veniva
collocato e restavano tutti impilati sull'angolo.

Verifica: da 54 a 60 controlli. I nuovi coprono i modelli di percorso, la
sostituzione dei caratteri illegali, il rifiuto dei segnaposto inventati, il
riconoscimento delle sessioni e la rilettura del DNG scritto - sia dal parser
interno sia dal decodificatore di sistema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 23:09:54 +02:00
co-authored by Claude Opus 5
parent 2e5a441938
commit db21a31e56
16 changed files with 3109 additions and 199 deletions
+126
View File
@@ -31,6 +31,7 @@ internal static class AdvancedModuleTests
Geometry(add);
Ramp(add);
Stacking(add);
Archive(workingDirectory, add);
Integration(workingDirectory, add, output);
}
@@ -585,6 +586,131 @@ internal static class AdvancedModuleTests
foreach (var frame in window) frame.Dispose();
}
// ================================================================== 7-bis. archivio
/// <summary>
/// Verifiche dell'importazione: i nomi che i modelli producono, il riconoscimento delle
/// sessioni, e la prova che il contenitore scritto dallo scrittore di immagini viene
/// riletto dal parser di questo stesso programma. Quest'ultima è la più severa: sono due
/// implementazioni indipendenti dello stesso formato, e se una delle due sbaglia si vede.
/// </summary>
private static void Archive(string workingDirectory, Report add)
{
var capture = new DateTime(2026, 8, 15, 21, 30, 45, DateTimeKind.Unspecified);
var metadata = new FrameMetadata
{
FilePath = @"K:\DCIM@GOPRO\G0011051.dng",
FileName = "G0011051.dng",
CaptureTime = capture,
ExposureSeconds = 10,
FNumber = 2.8,
Iso = 800,
PixelWidth = 4000,
PixelHeight = 3000,
Camera = "GoPro HERO8 Black",
};
var context = new NamingContext(metadata, 41, 2, capture.AddMinutes(-20), 7);
string folder = PathTemplate.Expand("{anno}/{data} {fotocamera}", context, allowSeparators: true);
string file = PathTemplate.Expand("{data}_{ora}_{n:0000}", context, allowSeparators: false);
string expectedFolder = Path.Combine("2026", "2026-08-15 GoPro HERO8 Black");
add("Modelli di percorso — cartella e nome file",
folder == expectedFolder && file == "2026-08-15_21-30-45_0042",
$"«{folder}» e «{file}»");
// Un tempo di posa contiene una barra: dentro un nome file non deve diventare un livello.
var fast = Variant(metadata, metadata.FileName, capture, 1.0 / 125);
string safe = PathTemplate.Expand("{posa}", new NamingContext(fast, 0, 0, capture, 0), false);
add("Modelli di percorso — caratteri illegali sostituiti",
safe.IndexOfAny(Path.GetInvalidFileNameChars()) < 0 && safe.Length > 0,
$"posa «{safe}»");
add("Modelli di percorso — segnaposto sconosciuto respinto",
PathTemplate.Validate("{anno}/{inesistente}", true) is not null &&
PathTemplate.Validate("{anno}/{data}", true) is null,
"il modello valido passa, quello con un segnaposto inventato no");
// ---- sessioni: due gruppi separati da una pausa lunga
var settings = new ImportSettings { GroupIntoSessions = true, SessionGapMinutes = 45, MinimumSessionFrames = 3 };
var files = new List<string>();
var stamps = new List<DateTime>();
for (int i = 0; i < 12; i++) stamps.Add(capture.AddMinutes(i));
for (int i = 0; i < 12; i++) stamps.Add(capture.AddMinutes(200 + i));
var candidates = new List<ImportCandidate>();
for (int i = 0; i < stamps.Count; i++)
{
candidates.Add(new ImportCandidate($"s{i}.dng",
Variant(metadata, $"s{i}.dng", stamps[i], metadata.ExposureSeconds), 1024, 0));
}
var grouped = MediaImporter.Regroup(candidates, settings);
int sessions = grouped.Select(c => c.SessionIndex).Distinct().Count();
add("Importazione — sessioni riconosciute sulle pause", sessions == 2,
$"{sessions} sessioni da 24 scatti con una pausa di tre ore nel mezzo");
_ = files;
// ---- scrittura e rilettura del contenitore
string path = Path.Combine(workingDirectory, "prova-lineare.dng");
var pool = new FrameBufferPool(4);
using (var frame = pool.Rent(64, 48))
{
for (int y = 0; y < 48; y++)
{
for (int x = 0; x < 64; x++)
{
int i = frame.Offset(x, y);
frame.Data[i] = x / 63f;
frame.Data[i + 1] = y / 47f;
frame.Data[i + 2] = 0.25f;
}
}
RasterWriter.Write(frame, path, metadata, RasterFormat.LinearDng, 92);
}
var reread = MetadataReader.Read(path);
bool sizeOk = reread.PixelWidth == 64 && reread.PixelHeight == 48;
bool exifOk = reread.Iso == 800 && reread.FNumber is { } f && Math.Abs(f - 2.8) < 0.01 &&
reread.CaptureTime == capture;
add("DNG lineare — riletto dal parser di Titano", sizeOk && exifOk,
$"{reread.PixelWidth}×{reread.PixelHeight}, ISO {reread.IsoText}, {reread.ApertureText}, " +
$"scatto {reread.CaptureTime:HH:mm:ss}");
// E il decodificatore di sistema deve saperlo aprire: è la prova che il file non è
// valido soltanto secondo chi lo ha scritto.
try
{
var (probeWidth, probeHeight) = ImageDecoder.ProbeDisplaySize(path, 1);
add("DNG lineare — aperto dal decodificatore di sistema",
probeWidth == 64 && probeHeight == 48, $"{probeWidth}×{probeHeight}");
}
catch (Exception ex)
{
add("DNG lineare — aperto dal decodificatore di sistema", false, ex.Message);
}
}
/// <summary>
/// Copia di metadati con qualche campo cambiato. FrameMetadata è una classe con proprietà
/// di sola inizializzazione, quindi non ha la copia con modifica dei record.
/// </summary>
private static FrameMetadata Variant(FrameMetadata source, string fileName, DateTime capture,
double? exposure) => new()
{
FilePath = source.FilePath,
FileName = fileName,
CaptureTime = capture,
ExposureSeconds = exposure,
FNumber = source.FNumber,
Iso = source.Iso,
PixelWidth = source.PixelWidth,
PixelHeight = source.PixelHeight,
Camera = source.Camera,
};
// ================================================================== 8. integrazione
/// <summary>
+10 -2
View File
@@ -49,9 +49,17 @@ public static class GeometryStage
{
var inverse = stabilization.Inverse;
// Il ritaglio prende il rapporto dell'uscita, non quello della sorgente: altrimenti
// passare da 4:3 a 16:9 stirerebbe l'immagine invece di tagliarla. A zoom uno si
// prende il rettangolo più grande con quel rapporto che ci sta dentro.
double zoom = Math.Max(1e-3, framing.Zoom);
double cropWidth = sourceWidth / zoom;
double cropHeight = sourceHeight / zoom;
double outputAspect = outputHeight > 0 ? outputWidth / (double)outputHeight
: sourceWidth / (double)Math.Max(1, sourceHeight);
double baseWidth = Math.Min(sourceWidth, sourceHeight * outputAspect);
double baseHeight = baseWidth / Math.Max(1e-6, outputAspect);
double cropWidth = baseWidth / zoom;
double cropHeight = baseHeight / zoom;
double left = framing.CentreX * sourceWidth - cropWidth * 0.5;
double top = framing.CentreY * sourceHeight - cropHeight * 0.5;
+381
View File
@@ -0,0 +1,381 @@
using System.Buffers.Binary;
using System.Drawing.Imaging;
using System.Globalization;
using System.Text;
using Titano.Metadata;
namespace Titano.Imaging;
/// <summary>Formato in cui l'importazione scrive i file convertiti.</summary>
public enum RasterFormat
{
/// <summary>TIFF a 16 bit per canale, codificato in sRGB: il formato di scambio classico.</summary>
Tiff16,
/// <summary>DNG lineare a 16 bit: stessi pixel, in luce lineare e con i tag che lo rendono un DNG.</summary>
LinearDng,
/// <summary>JPEG, tramite il codificatore di sistema.</summary>
Jpeg,
}
/// <summary>
/// Scrittura di immagini su file, con il contenitore costruito a mano.
///
/// <b>Cosa è, e cosa non è, il DNG che questo produce.</b> Un DNG "vero" contiene i valori
/// grezzi del sensore, prima dell'interpolazione cromatica, ed è quello che permette a un
/// programma di sviluppo di rifare da capo il bilanciamento del bianco e la demosaicizzazione.
/// Per ottenerlo da un formato proprietario servirebbe interpretarne il sensore modello per
/// modello — cioè la libreria del produttore, che il vincolo sulle dipendenze esclude.
///
/// Quello che si può fare in-house è un <i>DNG lineare</i>: la specifica lo prevede
/// (PhotometricInterpretation 34892) e contiene pixel già interpolati, in luce lineare, a 16
/// bit. È un DNG valido e apribile ovunque, ma non restituisce la libertà di sviluppo del
/// grezzo. Chi vuole conservare quella libertà copia il file com'è, e infatti la copia resta
/// l'impostazione predefinita dell'importazione.
/// </summary>
public static class RasterWriter
{
private const ushort TypeByte = 1;
private const ushort TypeAscii = 2;
private const ushort TypeShort = 3;
private const ushort TypeLong = 4;
private const ushort TypeRational = 5;
private const ushort TypeSRational = 10;
public static string ExtensionFor(RasterFormat format) => format switch
{
RasterFormat.Tiff16 => ".tif",
RasterFormat.LinearDng => ".dng",
_ => ".jpg",
};
public static void Write(ImageBuffer frame, string path, FrameMetadata metadata,
RasterFormat format, int jpegQuality)
{
switch (format)
{
case RasterFormat.Jpeg:
WriteJpeg(frame, path, jpegQuality);
break;
default:
WriteTiff(frame, path, metadata, format == RasterFormat.LinearDng);
break;
}
}
// ------------------------------------------------------------------ JPEG
private static unsafe void WriteJpeg(ImageBuffer frame, string path, int quality)
{
using var bitmap = new Bitmap(frame.Width, frame.Height, PixelFormat.Format24bppRgb);
var locked = bitmap.LockBits(new Rectangle(0, 0, frame.Width, frame.Height),
ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb);
try
{
var data = frame.Data;
byte* basePtr = (byte*)locked.Scan0;
for (int y = 0; y < frame.Height; y++)
{
byte* row = basePtr + (long)y * locked.Stride;
int source = y * frame.Width * ImageBuffer.Channels;
for (int x = 0; x < frame.Width; x++)
{
int i = source + x * ImageBuffer.Channels;
byte* pixel = row + x * 3;
pixel[0] = ColorSpace.ToSrgbByte(data[i + 2]); // B
pixel[1] = ColorSpace.ToSrgbByte(data[i + 1]); // G
pixel[2] = ColorSpace.ToSrgbByte(data[i]); // R
}
}
}
finally
{
bitmap.UnlockBits(locked);
}
var codec = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid);
using var parameters = new EncoderParameters(1);
using var setting = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality,
(long)Math.Clamp(quality, 40, 100));
parameters.Param[0] = setting;
bitmap.Save(path, codec, parameters);
}
// ------------------------------------------------------------------ TIFF e DNG
/// <summary>Voce di una directory TIFF, con il valore già ridotto a byte.</summary>
private sealed record Entry(ushort Tag, ushort Type, uint Count, byte[] Payload)
{
public bool Inline => Payload.Length <= 4;
}
private static void WriteTiff(ImageBuffer frame, string path, FrameMetadata metadata, bool asDng)
{
int width = frame.Width;
int height = frame.Height;
long pixelBytes = (long)width * height * 3 * sizeof(ushort);
var exif = BuildExifEntries(metadata, width, height);
var main = BuildMainEntries(metadata, width, height, asDng);
// Disposizione: intestazione, directory principale, directory Exif, valori fuori
// linea, pixel. Le dimensioni si conoscono tutte in anticipo, quindi gli scostamenti
// si calcolano senza dover tornare indietro a correggerli.
// Tre voci vengono aggiunte in coda — puntatore alla Exif, posizione e lunghezza dei
// pixel — e vanno contate qui: sbagliare di una voce sposta tutto ciò che segue di
// dodici byte, e il file resta apribile ma con i valori presi dal posto sbagliato.
const int appended = 3;
long ifdOffset = 8;
long ifdSize = 2 + 12L * (main.Count + appended) + 4;
long exifOffset = ifdOffset + ifdSize;
long exifSize = 2 + 12L * exif.Count + 4;
long valuesOffset = exifOffset + exifSize;
long cursor = valuesOffset;
var placement = new Dictionary<Entry, long>();
foreach (var entry in main.Concat(exif))
{
if (entry.Inline) continue;
placement[entry] = cursor;
cursor += entry.Payload.Length;
if ((cursor & 1) != 0) cursor++; // le voci partono su indirizzo pari
}
long stripOffset = cursor;
using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None,
1 << 20, FileOptions.SequentialScan);
Span<byte> header = stackalloc byte[8];
header[0] = (byte)'I';
header[1] = (byte)'I';
BinaryPrimitives.WriteUInt16LittleEndian(header[2..], 42);
BinaryPrimitives.WriteUInt32LittleEndian(header[4..], (uint)ifdOffset);
stream.Write(header);
// Le voci vanno in ordine di tag crescente: è un requisito del formato, non un vezzo.
var mainOrdered = main
.Append(new Entry(34665, TypeLong, 1, BitConverter.GetBytes((uint)exifOffset)))
.Append(new Entry(273, TypeLong, 1, BitConverter.GetBytes((uint)stripOffset)))
.Append(new Entry(279, TypeLong, 1, BitConverter.GetBytes((uint)pixelBytes)))
.OrderBy(e => e.Tag)
.ToList();
WriteDirectory(stream, mainOrdered, placement, 0);
WriteDirectory(stream, [.. exif.OrderBy(e => e.Tag)], placement, 0);
foreach (var entry in mainOrdered.Concat(exif))
{
if (entry.Inline) continue;
stream.Position = placement[entry];
stream.Write(entry.Payload);
}
stream.Position = stripOffset;
WritePixels(stream, frame, linear: asDng);
}
private static void WriteDirectory(Stream stream, List<Entry> entries,
Dictionary<Entry, long> placement, uint nextIfd)
{
Span<byte> count = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(count, (ushort)entries.Count);
stream.Write(count);
Span<byte> record = stackalloc byte[12];
foreach (var entry in entries)
{
record.Clear();
BinaryPrimitives.WriteUInt16LittleEndian(record, entry.Tag);
BinaryPrimitives.WriteUInt16LittleEndian(record[2..], entry.Type);
BinaryPrimitives.WriteUInt32LittleEndian(record[4..], entry.Count);
if (entry.Inline) entry.Payload.CopyTo(record[8..]);
else BinaryPrimitives.WriteUInt32LittleEndian(record[8..], (uint)placement[entry]);
stream.Write(record);
}
Span<byte> next = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(next, nextIfd);
stream.Write(next);
}
/// <summary>
/// Pixel a 16 bit. Il DNG lineare vuole luce lineare, che è ciò che il buffer contiene;
/// il TIFF va invece codificato in sRGB, perché è come qualunque visualizzatore lo
/// interpreterà. Scrivere gli stessi numeri in entrambi darebbe un TIFF molto scuro.
/// </summary>
private static void WritePixels(Stream stream, ImageBuffer frame, bool linear)
{
int width = frame.Width;
var data = frame.Data;
var row = new byte[width * 3 * sizeof(ushort)];
for (int y = 0; y < frame.Height; y++)
{
int source = y * width * ImageBuffer.Channels;
for (int x = 0; x < width; x++)
{
int i = source + x * ImageBuffer.Channels;
for (int channel = 0; channel < 3; channel++)
{
float value = data[i + channel];
float encoded = linear ? Math.Clamp(value, 0f, 1f) : ColorSpace.ToSrgb(value);
ushort level = (ushort)Math.Clamp((int)(encoded * 65535f + 0.5f), 0, 65535);
BinaryPrimitives.WriteUInt16LittleEndian(row.AsSpan((x * 3 + channel) * 2), level);
}
}
stream.Write(row);
}
}
private static List<Entry> BuildMainEntries(FrameMetadata metadata, int width, int height, bool asDng)
{
var entries = new List<Entry>
{
Long(254, 0),
Long(256, (uint)width),
Long(257, (uint)height),
Shorts(258, [16, 16, 16]),
Short(259, 1), // nessuna compressione
Short(262, asDng ? (ushort)34892 : (ushort)2), // LinearRaw oppure RGB
Short(274, 1),
Short(277, 3),
Long(278, (uint)height), // una striscia sola
Rational(282, 72, 1),
Rational(283, 72, 1),
Short(296, 2),
Ascii(305, "Titano"),
Shorts(339, [1, 1, 1]), // interi senza segno
};
if (metadata.Camera is { Length: > 0 } camera)
{
entries.Add(Ascii(271, camera));
entries.Add(Ascii(272, camera));
}
if (metadata.CaptureTime is { } capture)
{
entries.Add(Ascii(306, capture.ToString("yyyy:MM:dd HH:mm:ss", CultureInfo.InvariantCulture)));
}
if (!asDng) return entries;
entries.Add(Bytes(50706, [1, 4, 0, 0])); // DNGVersion
entries.Add(Bytes(50707, [1, 1, 0, 0])); // DNGBackwardVersion
entries.Add(Ascii(50708, metadata.Camera ?? "Titano linear"));
entries.Add(Rationals(50714, [(0, 1)])); // BlackLevel
entries.Add(Long(50717, 65535)); // WhiteLevel
entries.Add(Short(50778, 21)); // illuminante di calibrazione: D65
// Lo spazio "della fotocamera" qui è l'sRGB lineare, perché è da lì che arrivano i
// pixel: la matrice dichiarata è quindi la XYZ verso sRGB, non una risposta misurata
// su un sensore. Dichiararne una inventata sarebbe peggio che dichiarare questa.
entries.Add(SRationals(50721,
[
(3204454, 1000000), (-1537139, 1000000), (-498531, 1000000),
(-969266, 1000000), (1876011, 1000000), (41556, 1000000),
(55643, 1000000), (-204026, 1000000), (1057225, 1000000),
]));
entries.Add(Rationals(50728, [(1, 1), (1, 1), (1, 1)])); // AsShotNeutral
return entries;
}
private static List<Entry> BuildExifEntries(FrameMetadata metadata, int width, int height)
{
var entries = new List<Entry>
{
Long(40962, (uint)width),
Long(40963, (uint)height),
};
if (metadata.ExposureSeconds is { } exposure && exposure > 0)
{
var (numerator, denominator) = ToRational(exposure);
entries.Add(Rational(33434, numerator, denominator));
}
if (metadata.FNumber is { } aperture && aperture > 0)
{
entries.Add(Rational(33437, (uint)Math.Round(aperture * 100), 100));
}
if (metadata.Iso is { } iso && iso > 0)
{
entries.Add(Short(34855, (ushort)Math.Min(iso, ushort.MaxValue)));
}
if (metadata.FocalLength is { } focal && focal > 0)
{
entries.Add(Rational(37386, (uint)Math.Round(focal * 100), 100));
}
if (metadata.CaptureTime is { } capture)
{
entries.Add(Ascii(36867, capture.ToString("yyyy:MM:dd HH:mm:ss", CultureInfo.InvariantCulture)));
}
return entries;
}
/// <summary>Frazione che rappresenta un tempo di posa senza perdere i valori tipici (1/125, 1/8000).</summary>
private static (uint Numerator, uint Denominator) ToRational(double value)
{
if (value >= 1) return ((uint)Math.Round(value * 1000), 1000);
double inverse = 1.0 / value;
return (1, (uint)Math.Clamp(Math.Round(inverse), 1, uint.MaxValue));
}
// ---- costruttori di voce
private static Entry Short(ushort tag, ushort value)
{
var payload = new byte[4];
BinaryPrimitives.WriteUInt16LittleEndian(payload, value);
return new Entry(tag, TypeShort, 1, payload);
}
private static Entry Shorts(ushort tag, ushort[] values)
{
var payload = new byte[values.Length * 2];
for (int i = 0; i < values.Length; i++)
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(i * 2), values[i]);
return new Entry(tag, TypeShort, (uint)values.Length, payload);
}
private static Entry Long(ushort tag, uint value)
=> new(tag, TypeLong, 1, BitConverter.GetBytes(value));
private static Entry Bytes(ushort tag, byte[] values)
=> new(tag, TypeByte, (uint)values.Length, values);
private static Entry Ascii(ushort tag, string value)
{
var payload = Encoding.ASCII.GetBytes(value + "\0");
return new Entry(tag, TypeAscii, (uint)payload.Length, payload);
}
private static Entry Rational(ushort tag, uint numerator, uint denominator)
=> Rationals(tag, [(numerator, denominator)]);
private static Entry Rationals(ushort tag, (uint Numerator, uint Denominator)[] values)
{
var payload = new byte[values.Length * 8];
for (int i = 0; i < values.Length; i++)
{
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(i * 8), values[i].Numerator);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(i * 8 + 4), values[i].Denominator);
}
return new Entry(tag, TypeRational, (uint)values.Length, payload);
}
private static Entry SRationals(ushort tag, (int Numerator, int Denominator)[] values)
{
var payload = new byte[values.Length * 8];
for (int i = 0; i < values.Length; i++)
{
BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(i * 8), values[i].Numerator);
BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(i * 8 + 4), values[i].Denominator);
}
return new Entry(tag, TypeSRational, (uint)values.Length, payload);
}
}
+314
View File
@@ -0,0 +1,314 @@
using Titano.Analysis;
using Titano.Core;
using Titano.Motion;
namespace Titano.Pipeline;
/// <summary>
/// Rilevamento delle impostazioni ottimali per una singola sezione.
///
/// Si distingue dal direttore, che lavora di continuo sui pochi parametri deducibili senza
/// ambiguità: questo si invoca a mano e prende decisioni più impegnative, comprese quelle di
/// accendere o spegnere interi moduli. Il motivo per cui va chiesto e non fatto da sé è che
/// cambia scelte che l'utente potrebbe aver preso apposta, e sostituirgliele senza dirlo
/// sarebbe peggio che lasciarle sbagliate.
///
/// Ogni decisione torna indietro come una frase: alla fine si legge cosa è cambiato e perché,
/// e se non convince si annulla a mano. Dove il dato manca — la sequenza non è ancora stata
/// analizzata — non si tira a indovinare: si dice che manca.
/// </summary>
public static class AutoOptimizer
{
public static List<string> Optimize(TitanoProject project, WorkspaceArea area)
{
var changes = new List<string>();
if (project.Sequence is not { Count: > 0 }) return ["Nessuna sequenza caricata."];
switch (area)
{
case WorkspaceArea.Sequence: OptimizeReading(project, changes); break;
case WorkspaceArea.Exposure: OptimizeExposure(project, changes); break;
case WorkspaceArea.Motion: OptimizeMotion(project, changes); break;
case WorkspaceArea.Timing: OptimizeTiming(project, changes); break;
default: OptimizeExport(project, changes); break;
}
if (changes.Count == 0) changes.Add("Nessuna modifica: le impostazioni erano già adeguate.");
return changes;
}
// ------------------------------------------------------------------ lettura
private static void OptimizeReading(TitanoProject project, List<string> changes)
{
var decisions = AutoDirector.Derive(project);
project.ManualParameters.Remove(AutoKey.AnalysisWidth);
project.ManualParameters.Remove(AutoKey.MemoryBudget);
AutoDirector.Apply(project, decisions, project.ManualParameters);
if (decisions.TryGetValue(AutoKey.AnalysisWidth, out var width))
changes.Add($"Passata di analisi a {width.Value:0} px: {width.Reason}.");
if (decisions.TryGetValue(AutoKey.MemoryBudget, out var memory))
changes.Add($"Tetto di memoria a {memory.Value:0} MiB: {memory.Reason}.");
// Le decodifiche simultanee non si spingono al numero di processori: la decodifica di
// un RAW è dominata dall'accesso al file, e oltre un certo punto i thread si
// contendono il disco invece di sommarsi.
int parallelism = Math.Clamp(Environment.ProcessorCount / 2, 2, 8);
if (project.General.DecodeParallelism != parallelism)
{
project.General.DecodeParallelism = parallelism;
changes.Add($"Decodifiche simultanee a {parallelism}: metà dei processori logici.");
}
// Una sequenza lunga guadagna da una lettura in anticipo profonda; una corta no,
// e occuperebbe memoria per fotogrammi che non fa in tempo a usare.
int prefetch = project.Sequence!.Count > 200 ? 12 : 6;
if (project.Cache.PrefetchDepth != prefetch)
{
project.Cache.PrefetchDepth = prefetch;
changes.Add($"Lettura in anticipo a {prefetch} fotogrammi.");
}
}
// ------------------------------------------------------------------ esposizione
private static void OptimizeExposure(TitanoProject project, List<string> changes)
{
if (project.Curve is not { Count: > 4 } curve || project.Stats is null)
{
changes.Add("Analizza la sequenza: senza le misure non c'è nulla su cui decidere.");
return;
}
double flicker = DeflickerCurve.FlickerIndex(curve.Measured);
double span = curve.Measured.Max() - curve.Measured.Min();
if (!project.Deflicker.Enabled && flicker > 0.02)
{
project.Deflicker.Enabled = true;
changes.Add($"Deflicker acceso: lo sfarfallio misurato è {flicker:0.000} EV.");
}
// La correzione massima deve coprire lo sfarfallio con margine, ma non tanto da poter
// inseguire un cambio di luce vero: tre volte lo scarto tipico è il compromesso che
// lascia passare le rampe e blocca gli sbandamenti.
double limit = Math.Clamp(Math.Round(flicker * 6, 1), 0.5, 2.5);
if (Math.Abs(project.Deflicker.MaxCorrectionStops - limit) > 0.05)
{
project.Deflicker.MaxCorrectionStops = limit;
changes.Add($"Correzione massima a {limit:0.0} EV, sei volte lo sfarfallio misurato.");
}
var decisions = AutoDirector.Derive(project);
project.ManualParameters.Remove(AutoKey.DeflickerWindow);
project.ManualParameters.Remove(AutoKey.TransitionFrames);
AutoDirector.Apply(project, decisions, project.ManualParameters);
if (decisions.TryGetValue(AutoKey.DeflickerWindow, out var window))
changes.Add($"Finestra del deflicker a {window.Value:0}: {window.Reason}.");
// Le transizioni servono solo se ci sono davvero dei gradini da ammorbidire.
bool hasSteps = project.Transitions is { StepCount: > 0 };
if (hasSteps != project.HolyGrail.Enabled)
{
project.HolyGrail.Enabled = hasSteps;
changes.Add(hasSteps
? $"Transizioni accese: {project.Transitions!.StepCount} cambi di impostazione rilevati."
: "Transizioni spente: nessun cambio di impostazione nella sequenza.");
}
// Una sequenza con poca escursione di luce non ha regioni che si comportino in modo
// diverso: la divisione aggiungerebbe complessità senza cambiare il risultato.
var suggested = span > 1.5 ? RegionMode.SkyGround : RegionMode.Off;
if (project.Regions.Mode != suggested)
{
project.Regions.Mode = suggested;
changes.Add(suggested == RegionMode.Off
? $"Divisione in regioni spenta: l'escursione di luce è di soli {span:0.0} EV."
: $"Divisione per orizzonte accesa: {span:0.0} EV di escursione la rendono utile.");
}
// Le alte luci vanno protette quando la correzione schiarisce e c'è già del saturo.
bool anyClipping = project.Stats.Any(s => s.ClippedFraction > 0.01);
if (anyClipping && !project.Deflicker.ProtectHighlights)
{
project.Deflicker.ProtectHighlights = true;
changes.Add("Protezione delle alte luci accesa: la sequenza contiene zone sature.");
}
}
// ------------------------------------------------------------------ movimento
private static void OptimizeMotion(TitanoProject project, List<string> changes)
{
var (sourceWidth, _) = project.ResolveSourceSize();
double toPixels = Math.Max(1, sourceWidth);
if (project.Motion is { } motion)
{
double shake = motion.MeanShake * toPixels;
// Sotto un terzo di pixel il tremolio non si vede nemmeno a schermo intero, e
// stabilizzare costerebbe il ritaglio senza restituire nulla.
bool worth = shake > 0.3;
if (worth != project.Stabilization.Enabled)
{
project.Stabilization.Enabled = worth;
changes.Add(worth
? $"Stabilizzazione accesa: il tremolio medio è di {shake:0.00} px."
: $"Stabilizzazione spenta: {shake:0.00} px di tremolio non si vedono.");
}
if (worth)
{
var decisions = AutoDirector.Derive(project);
project.ManualParameters.Remove(AutoKey.StabilizationWindow);
AutoDirector.Apply(project, decisions, project.ManualParameters);
if (decisions.TryGetValue(AutoKey.StabilizationWindow, out var window))
changes.Add($"Finestra del percorso a {window.Value:0}: {window.Reason}.");
// Il limite di correzione si allinea a quanto serve davvero, con un margine:
// più largo del necessario significa solo più ritaglio.
double needed = Math.Clamp(motion.MaxCorrection * 1.4, 0.01, 0.15);
if (Math.Abs(project.Stabilization.MaxCorrectionFraction - needed) > 0.004)
{
project.Stabilization.MaxCorrectionFraction = Math.Round(needed, 3);
changes.Add($"Correzione massima a {needed * 100:0.#}% della larghezza, " +
$"con margine su quanto misurato.");
}
}
if (motion.UnreliableFraction > 0.35 && project.Stabilization.Enabled)
{
changes.Add($"Attenzione: sul {motion.UnreliableFraction * 100:0}% delle coppie la " +
$"correlazione è debole, di solito per fotogrammi bruciati o senza tessitura.");
}
}
else
{
changes.Add("Attiva la stabilizzazione e analizza per misurare il tremolio.");
}
// La sfocatura sintetica ha senso solo se c'è movimento da sfocare.
if (project.Sequence is { Count: > 1 } sequence)
{
double motionMagnitude = sequence.Frames.Take(64).Select(f => f.MotionMagnitude).DefaultIfEmpty(0).Max();
if (motionMagnitude > 0)
{
bool worthBlur = motionMagnitude > 0.8;
if (worthBlur != project.MotionBlur.Enabled)
{
project.MotionBlur.Enabled = worthBlur;
changes.Add(worthBlur
? $"Sfocatura accesa: lo spostamento arriva a {motionMagnitude:0.0} px fra fotogrammi."
: "Sfocatura spenta: il movimento fra fotogrammi è troppo piccolo da sfocare.");
}
}
}
}
// ------------------------------------------------------------------ tempo
private static void OptimizeTiming(TitanoProject project, List<string> changes)
{
if (project.Curve is not { Count: > 24 } curve)
{
changes.Add("Analizza la sequenza: la curva di velocità si ricava dalla luce misurata.");
return;
}
// Rallentare dove la luce cambia più in fretta e correre dove non succede niente:
// è il tramonto che merita i fotogrammi, non l'ora di buio prima.
int count = curve.Count;
const int knots = 9;
var speeds = new double[knots];
for (int k = 0; k < knots; k++)
{
int centre = (int)Math.Round(k * (count - 1.0) / (knots - 1));
int from = Math.Max(1, centre - count / (2 * knots));
int to = Math.Min(count - 1, centre + count / (2 * knots));
double change = 0;
int samples = 0;
for (int i = from; i <= to; i++)
{
change += Math.Abs(curve.Target[i] - curve.Target[i - 1]);
samples++;
}
speeds[k] = samples > 0 ? change / samples : 0;
}
double busiest = speeds.Max();
if (busiest < 1e-4)
{
changes.Add("La luce non cambia abbastanza da giustificare una rimappatura.");
if (project.TimeRamp.Enabled)
{
project.TimeRamp.Enabled = false;
changes.Add("Curva di velocità spenta.");
}
return;
}
var curveKnots = new List<SplineKnot>(knots);
for (int k = 0; k < knots; k++)
{
// Dove la luce cambia di più la velocità scende verso 0,5; dove è ferma sale a 2,5.
double activity = speeds[k] / busiest;
double speed = 2.5 - 2.0 * activity;
curveKnots.Add(new SplineKnot(k / (knots - 1.0), Math.Round(Math.Clamp(speed, 0.4, 2.5), 2)));
}
project.TimeRamp.Speed = curveKnots;
project.TimeRamp.Enabled = true;
changes.Add($"Curva di velocità ricavata dalla luce: da {curveKnots.Min(k => k.Y):0.##}× " +
$"nei passaggi rapidi a {curveKnots.Max(k => k.Y):0.##}× dove la scena è ferma.");
// Con la rimappatura attiva i fotogrammi mancanti vanno sintetizzati, e per farlo
// serve il campo vettoriale: la modalità a durata costante lo renderebbe inutile.
if (project.Export.Timing != Video.FrameTimingMode.Constant)
{
project.Export.Timing = Video.FrameTimingMode.Constant;
changes.Add("Durata dei fotogrammi riportata a costante: la rimappatura governa già il ritmo.");
}
}
// ------------------------------------------------------------------ esportazione
private static void OptimizeExport(TitanoProject project, List<string> changes)
{
var (width, height) = project.ResolveWorkingSize();
if (width <= 0) return;
// Bitrate proporzionale ai pixel al secondo. Il coefficiente è quello che per H.264
// tiene insieme un cielo notturno senza banding evidente; HEVC ottiene lo stesso con
// circa il trenta per cento in meno.
double pixelsPerSecond = (double)width * height * project.Export.FrameRate;
double bits = pixelsPerSecond * (project.Export.Codec == Video.VideoCodec.H264 ? 0.085 : 0.060);
double suggested = Math.Clamp(Math.Round(bits / 1_000_000.0 / 5) * 5, 10, 250);
if (Math.Abs(project.Export.BitrateMbps - suggested) > 2)
{
project.Export.BitrateMbps = suggested;
changes.Add($"Bitrate a {suggested:0} Mb/s per {width}×{height} a {project.Export.FrameRate:0} fps.");
}
var (requestedWidth, requestedHeight) = project.ResolveRequestedSize();
if (width != requestedWidth || height != requestedHeight)
{
changes.Add($"La risoluzione resta {width}×{height}: {requestedWidth}×{requestedHeight} " +
$"eccede il livello che i lettori supportano.");
}
int keyframe = project.Export.FrameRate >= 48 ? 2 : 3;
if (project.Export.KeyframeIntervalSeconds != keyframe)
{
project.Export.KeyframeIntervalSeconds = keyframe;
changes.Add($"Fotogrammi chiave ogni {keyframe} s.");
}
}
}
+497
View File
@@ -0,0 +1,497 @@
using System.Security.Cryptography;
using Titano.Imaging;
using Titano.Metadata;
namespace Titano.Pipeline;
/// <summary>Cosa fare del file durante l'importazione.</summary>
public enum ImportAction
{
/// <summary>Copia il file com'è: nessuna interpretazione, nessuna perdita.</summary>
Copy,
/// <summary>Converte in TIFF a 16 bit codificato in sRGB.</summary>
ConvertToTiff,
/// <summary>Converte in DNG lineare a 16 bit.</summary>
ConvertToDng,
/// <summary>Converte in JPEG.</summary>
ConvertToJpeg,
}
/// <summary>Come comportarsi quando la destinazione esiste già.</summary>
public enum ImportCollision
{
/// <summary>Salta il file: è quello che si vuole reimportando una scheda già scaricata.</summary>
Skip,
/// <summary>Aggiunge un suffisso numerico.</summary>
Rename,
/// <summary>Sovrascrive.</summary>
Overwrite,
}
/// <summary>Impostazioni dell'importazione da scheda o cartella.</summary>
public sealed class ImportSettings
{
public string SourcePath { get; set; } = string.Empty;
public bool SearchSubfolders { get; set; } = true;
public string DestinationRoot { get; set; } = string.Empty;
public string FolderTemplate { get; set; } = "{anno}/{data} {fotocamera}";
public string FileTemplate { get; set; } = "{data}_{ora}_{n:0000}";
public ImportAction Action { get; set; } = ImportAction.Copy;
public ImportCollision Collision { get; set; } = ImportCollision.Skip;
/// <summary>Conserva anche l'originale quando si converte.</summary>
public bool KeepOriginalWhenConverting { get; set; } = true;
/// <summary>Rilegge il file scritto e ne confronta l'impronta con la sorgente.</summary>
public bool VerifyCopy { get; set; } = true;
/// <summary>
/// Divide gli scatti in sessioni quando fra due c'è una pausa più lunga di così.
/// Una scheda contiene spesso più riprese, e finirebbero mescolate in una cartella sola.
/// </summary>
public bool GroupIntoSessions { get; set; } = true;
public double SessionGapMinutes { get; set; } = 45;
/// <summary>Numero minimo di scatti perché un gruppo valga come sessione a sé.</summary>
public int MinimumSessionFrames { get; set; } = 8;
public int JpegQuality { get; set; } = 92;
/// <summary>Larghezza massima dei file convertiti; 0 conserva quella originale.</summary>
public int ConvertedWidth { get; set; }
public ImportSettings Clone() => (ImportSettings)MemberwiseClone();
public RasterFormat Format => Action switch
{
ImportAction.ConvertToTiff => RasterFormat.Tiff16,
ImportAction.ConvertToDng => RasterFormat.LinearDng,
_ => RasterFormat.Jpeg,
};
}
/// <summary>Un supporto da cui si può importare.</summary>
public sealed record ImportVolume(string Path, string Label, long FreeBytes, long TotalBytes, bool Removable)
{
public string Description => Removable
? $"{Label} — supporto rimovibile, {TotalBytes / (1024.0 * 1024 * 1024):0.#} GB"
: $"{Label} — {TotalBytes / (1024.0 * 1024 * 1024):0.#} GB";
}
/// <summary>Un file candidato con i suoi metadati già letti.</summary>
public sealed record ImportCandidate(string SourcePath, FrameMetadata Metadata, long Size, int SessionIndex);
/// <summary>Dove finirà un singolo file, e perché.</summary>
public sealed record ImportStep(ImportCandidate Candidate, string DestinationPath, string? OriginalCopyPath, bool Skipped);
/// <summary>Il piano completo, prima di toccare il disco.</summary>
public sealed class ImportPlan
{
public required IReadOnlyList<ImportStep> Steps { get; init; }
public required int SessionCount { get; init; }
public required long TotalBytes { get; init; }
public int Pending => Steps.Count(s => !s.Skipped);
public int Skipped => Steps.Count(s => s.Skipped);
/// <summary>Cartelle che verranno create, in ordine.</summary>
public IEnumerable<string> Folders => Steps
.Where(s => !s.Skipped)
.Select(s => Path.GetDirectoryName(s.DestinationPath) ?? string.Empty)
.Where(f => f.Length > 0)
.Distinct()
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase);
}
/// <summary>Esito dell'importazione.</summary>
public sealed record ImportResult(int Imported, int Skipped, int Failed, long Bytes, TimeSpan Elapsed,
IReadOnlyList<string> Errors, string? FirstFolder);
/// <summary>
/// Importazione da scheda di memoria o cartella.
///
/// Fa tre cose che a mano si sbagliano sempre. Riconosce le sessioni: una scheda contiene di
/// norma più riprese, e scaricarle in una cartella sola significa doverle separare dopo, a
/// occhio, guardando gli orari. Costruisce i nomi da una regola invece che dall'ispirazione
/// del momento, così l'archivio resta ordinabile e confrontabile anche fra anni diversi. E
/// verifica ciò che ha scritto rileggendolo, perché una scheda che si scollega a metà copia
/// produce file di dimensione giusta e contenuto troncato, e ci si accorge del danno mesi
/// dopo, quando l'originale non c'è più.
/// </summary>
public static class MediaImporter
{
/// <summary>Nomi di cartella che indicano una scheda fotografica.</summary>
private static readonly string[] CameraFolders = ["DCIM", "PRIVATE", "MISC", "CANONMSC"];
/// <summary>Supporti disponibili, con quelli rimovibili in cima.</summary>
public static List<ImportVolume> Volumes()
{
var volumes = new List<ImportVolume>();
foreach (var drive in DriveInfo.GetDrives())
{
try
{
if (!drive.IsReady) continue;
if (drive.DriveType is not (DriveType.Removable or DriveType.Fixed)) continue;
bool removable = drive.DriveType == DriveType.Removable || LooksLikeCamera(drive.RootDirectory.FullName);
string label = string.IsNullOrWhiteSpace(drive.VolumeLabel)
? drive.Name
: $"{drive.Name.TrimEnd('\\')} {drive.VolumeLabel}";
volumes.Add(new ImportVolume(drive.RootDirectory.FullName, label,
drive.AvailableFreeSpace, drive.TotalSize, removable));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Un'unità che non risponde non deve impedire di elencare le altre.
}
}
return [.. volumes.OrderByDescending(v => v.Removable).ThenBy(v => v.Path, StringComparer.OrdinalIgnoreCase)];
}
/// <summary>Vero se la radice contiene una delle cartelle che le fotocamere creano.</summary>
public static bool LooksLikeCamera(string root)
{
try
{
return CameraFolders.Any(folder => Directory.Exists(Path.Combine(root, folder)));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return false;
}
}
/// <summary>
/// Elenca i file importabili e ne legge i metadati, raggruppandoli in sessioni.
/// La lettura dei metadati serve subito: senza data di scatto non si possono né ordinare
/// né dare loro un nome, e leggerli due volte costerebbe il doppio su mille file.
/// </summary>
public static List<ImportCandidate> Scan(ImportSettings settings, IProgress<PipelineProgress>? progress,
CancellationToken cancellation)
{
if (!Directory.Exists(settings.SourcePath)) return [];
var option = settings.SearchSubfolders ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
List<string> files;
try
{
files = [.. Directory.EnumerateFiles(settings.SourcePath, "*", new EnumerationOptions
{
RecurseSubdirectories = settings.SearchSubfolders,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.System,
}).Where(MetadataReader.IsSupported)];
_ = option;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return [];
}
var metadata = new FrameMetadata[files.Count];
var sizes = new long[files.Count];
int done = 0;
Parallel.For(0, files.Count, new ParallelOptions
{
CancellationToken = cancellation,
MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount / 2, 1, 8),
}, i =>
{
metadata[i] = MetadataReader.Read(files[i]);
try { sizes[i] = new FileInfo(files[i]).Length; } catch (IOException) { sizes[i] = 0; }
int completed = Interlocked.Increment(ref done);
if (completed % 32 == 0 || completed == files.Count)
{
progress?.Report(new PipelineProgress(PipelinePhase.Ingestion, completed, files.Count,
"Lettura della scheda…"));
}
});
var ordered = Enumerable.Range(0, files.Count)
.Select(i => (Path: files[i], Metadata: metadata[i], Size: sizes[i]))
.OrderBy(f => f.Metadata.CaptureTime ?? DateTime.MaxValue)
.ThenBy(f => f.Metadata.FileName, Core.NaturalFileNameComparer.Instance)
.ToList();
return AssignSessions(ordered, settings);
}
/// <summary>
/// Riassegna le sessioni a un elenco di candidati già ordinato per istante di scatto.
/// Serve a ricalcolare i gruppi quando si sposta la soglia della pausa senza rileggere
/// la scheda, ed è anche il punto da cui la verifica automatica controlla la regola.
/// </summary>
public static List<ImportCandidate> Regroup(IReadOnlyList<ImportCandidate> candidates, ImportSettings settings)
{
var ordered = candidates
.OrderBy(c => c.Metadata.CaptureTime ?? DateTime.MaxValue)
.ThenBy(c => c.Metadata.FileName, Core.NaturalFileNameComparer.Instance)
.Select(c => (c.SourcePath, c.Metadata, c.Size))
.ToList();
return AssignSessions(ordered, settings);
}
/// <summary>
/// Divide gli scatti in sessioni sulle pause. Un gruppo troppo piccolo non diventa una
/// sessione a sé: verrebbe una cartella con tre file, quando quasi sempre si tratta degli
/// scatti di prova fatti prima di cominciare davvero.
/// </summary>
private static List<ImportCandidate> AssignSessions(
List<(string Path, FrameMetadata Metadata, long Size)> ordered, ImportSettings settings)
{
var candidates = new List<ImportCandidate>(ordered.Count);
if (ordered.Count == 0) return candidates;
if (!settings.GroupIntoSessions)
{
foreach (var file in ordered) candidates.Add(new ImportCandidate(file.Path, file.Metadata, file.Size, 0));
return candidates;
}
var gap = TimeSpan.FromMinutes(Math.Max(1, settings.SessionGapMinutes));
var groups = new List<List<(string Path, FrameMetadata Metadata, long Size)>>();
groups.Add([ordered[0]]);
for (int i = 1; i < ordered.Count; i++)
{
var previous = ordered[i - 1].Metadata.CaptureTime;
var current = ordered[i].Metadata.CaptureTime;
bool newSession = previous is { } a && current is { } b && b - a > gap;
if (newSession) groups.Add([]);
groups[^1].Add(ordered[i]);
}
// I gruppi minuscoli confluiscono nel precedente invece di generare una cartella propria.
var merged = new List<List<(string Path, FrameMetadata Metadata, long Size)>>();
foreach (var group in groups)
{
if (merged.Count > 0 && group.Count < Math.Max(1, settings.MinimumSessionFrames))
{
merged[^1].AddRange(group);
continue;
}
merged.Add(group);
}
for (int session = 0; session < merged.Count; session++)
{
foreach (var file in merged[session])
{
candidates.Add(new ImportCandidate(file.Path, file.Metadata, file.Size, session));
}
}
return candidates;
}
/// <summary>
/// Costruisce il piano senza scrivere nulla: percorsi di destinazione, collisioni,
/// cartelle da creare. Serve a poterlo mostrare prima di eseguirlo, perché
/// un'importazione sbagliata su mille file non si annulla.
/// </summary>
public static ImportPlan Plan(IReadOnlyList<ImportCandidate> candidates, ImportSettings settings)
{
var steps = new List<ImportStep>(candidates.Count);
var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var sessionStarts = new Dictionary<int, DateTime>();
var sessionCounters = new Dictionary<int, int>();
long bytes = 0;
foreach (var candidate in candidates)
{
if (!sessionStarts.ContainsKey(candidate.SessionIndex))
{
sessionStarts[candidate.SessionIndex] = candidate.Metadata.CaptureTime ?? DateTime.Now;
}
}
for (int i = 0; i < candidates.Count; i++)
{
var candidate = candidates[i];
int withinSession = sessionCounters.GetValueOrDefault(candidate.SessionIndex);
sessionCounters[candidate.SessionIndex] = withinSession + 1;
var context = new NamingContext(candidate.Metadata, i, candidate.SessionIndex,
sessionStarts[candidate.SessionIndex], withinSession);
string folder = PathTemplate.Expand(settings.FolderTemplate, context, allowSeparators: true);
string name = PathTemplate.Expand(settings.FileTemplate, context, allowSeparators: false);
if (name.Length == 0) name = Path.GetFileNameWithoutExtension(candidate.Metadata.FileName);
string extension = settings.Action == ImportAction.Copy
? Path.GetExtension(candidate.SourcePath)
: RasterWriter.ExtensionFor(settings.Format);
string directory = Path.Combine(settings.DestinationRoot, folder);
string destination = Path.Combine(directory, name + extension);
bool skipped = false;
if (taken.Contains(destination) || File.Exists(destination))
{
switch (settings.Collision)
{
case ImportCollision.Skip:
skipped = true;
break;
case ImportCollision.Rename:
destination = Unique(destination, taken);
break;
}
}
string? original = null;
if (!skipped && settings.Action != ImportAction.Copy && settings.KeepOriginalWhenConverting)
{
original = Path.Combine(directory, "originali",
name + Path.GetExtension(candidate.SourcePath));
}
if (!skipped)
{
taken.Add(destination);
bytes += candidate.Size;
}
steps.Add(new ImportStep(candidate, destination, original, skipped));
}
return new ImportPlan
{
Steps = steps,
SessionCount = sessionStarts.Count,
TotalBytes = bytes,
};
}
private static string Unique(string path, HashSet<string> taken)
{
string directory = Path.GetDirectoryName(path) ?? string.Empty;
string name = Path.GetFileNameWithoutExtension(path);
string extension = Path.GetExtension(path);
for (int suffix = 2; suffix < 10000; suffix++)
{
string candidate = Path.Combine(directory, $"{name}-{suffix}{extension}");
if (!taken.Contains(candidate) && !File.Exists(candidate)) return candidate;
}
return path;
}
/// <summary>Esegue il piano.</summary>
public static ImportResult Execute(ImportPlan plan, ImportSettings settings,
IProgress<PipelineProgress>? progress, CancellationToken cancellation)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var errors = new List<string>();
int imported = 0, failed = 0;
long bytes = 0;
string? firstFolder = null;
var pending = plan.Steps.Where(s => !s.Skipped).ToList();
var pool = new FrameBufferPool(4);
for (int i = 0; i < pending.Count; i++)
{
cancellation.ThrowIfCancellationRequested();
var step = pending[i];
try
{
string directory = Path.GetDirectoryName(step.DestinationPath)!;
Directory.CreateDirectory(directory);
firstFolder ??= directory;
if (settings.Action == ImportAction.Copy)
{
CopyVerified(step.Candidate.SourcePath, step.DestinationPath, settings.VerifyCopy);
}
else
{
Convert(step, settings, pool);
if (step.OriginalCopyPath is { } original)
{
Directory.CreateDirectory(Path.GetDirectoryName(original)!);
CopyVerified(step.Candidate.SourcePath, original, settings.VerifyCopy);
}
}
imported++;
bytes += step.Candidate.Size;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
failed++;
if (errors.Count < 20) errors.Add($"{step.Candidate.Metadata.FileName}: {ex.Message}");
}
progress?.Report(new PipelineProgress(PipelinePhase.Ingestion, i + 1, pending.Count,
"Importazione…"));
}
stopwatch.Stop();
return new ImportResult(imported, plan.Skipped, failed, bytes, stopwatch.Elapsed, errors, firstFolder);
}
/// <summary>
/// Copia e, se richiesto, rilegge entrambi i file confrontandone l'impronta.
///
/// Non è zelo: una scheda scollegata a metà scrittura, o un lettore che comincia a
/// sbagliare, producono file della dimensione giusta e del contenuto sbagliato. Senza
/// verifica il danno si scopre mesi dopo, quando l'originale è stato formattato.
/// </summary>
private static void CopyVerified(string source, string destination, bool verify)
{
File.Copy(source, destination, overwrite: true);
if (!verify) return;
if (!Hash(source).SequenceEqual(Hash(destination)))
{
try { File.Delete(destination); } catch (IOException) { }
throw new IOException("La verifica dopo la copia non coincide: file rimosso.");
}
}
private static byte[] Hash(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
1 << 20, FileOptions.SequentialScan);
return SHA256.HashData(stream);
}
private static void Convert(ImportStep step, ImportSettings settings, FrameBufferPool pool)
{
var metadata = step.Candidate.Metadata;
int orientation = metadata.Orientation is >= 1 and <= 8 ? metadata.Orientation : 1;
var (width, height) = ImageDecoder.ProbeDisplaySize(step.Candidate.SourcePath, orientation);
if (width <= 0 || height <= 0) throw new InvalidDataException("Dimensioni non leggibili.");
if (settings.ConvertedWidth > 0 && settings.ConvertedWidth < width)
{
height = Math.Max(2, (int)Math.Round(settings.ConvertedWidth * height / (double)width));
width = settings.ConvertedWidth;
}
using var buffer = ImageDecoder.Decode(step.Candidate.SourcePath, width, height, orientation, pool);
RasterWriter.Write(buffer, step.DestinationPath, metadata, settings.Format, settings.JpegQuality);
}
}
+186
View File
@@ -0,0 +1,186 @@
using System.Globalization;
using System.Text;
using Titano.Metadata;
namespace Titano.Pipeline;
/// <summary>Dati che un segnaposto può leggere: lo scatto e la sua posizione nell'importazione.</summary>
public readonly record struct NamingContext(
FrameMetadata Metadata,
int Index,
int SessionIndex,
DateTime SessionStart,
int SessionCount);
/// <summary>
/// Espansione dei segnaposto nei nomi di cartelle e file.
///
/// Il punto non è risparmiare battute: è che un archivio si consulta anni dopo, e il nome è
/// l'unica cosa che resta leggibile senza aprire nulla. Una regola scritta una volta produce
/// per sempre nomi coerenti, ordinabili e che dicono quando e con cosa è stato scattato —
/// dove invece la mano cambia idea a ogni cartella.
///
/// La sintassi è {segnaposto} oppure {segnaposto:formato}, con il formato passato così com'è
/// alla conversione numerica o di data. I caratteri che il file system non accetta vengono
/// sostituiti, mai lasciati passare: un segnaposto che restituisce un tempo di posa non deve
/// creare una sottocartella per via della barra.
/// </summary>
public static class PathTemplate
{
/// <summary>Segnaposto riconosciuti, con la descrizione mostrata nell'interfaccia.</summary>
public static readonly (string Token, string Description)[] Tokens =
[
("anno", "anno dello scatto, quattro cifre"),
("mese", "mese dello scatto, due cifre"),
("giorno", "giorno dello scatto, due cifre"),
("data", "data dello scatto come 2026-08-15"),
("ora", "ora dello scatto come 21-30-45"),
("oraminuti", "ora e minuti come 21-30"),
("sessione", "numero della sessione riconosciuta nella scheda"),
("iniziosessione", "istante del primo scatto della sessione"),
("fotocamera", "marca e modello dai metadati"),
("obiettivo", "obiettivo dai metadati"),
("iso", "sensibilità dello scatto"),
("posa", "tempo di posa, con la barra sostituita"),
("diaframma", "apertura come f2.8"),
("n", "numero progressivo nell'importazione"),
("nsessione", "numero progressivo dentro la sessione"),
("nome", "nome originale del file, senza estensione"),
("estensione", "estensione originale, senza il punto"),
];
/// <summary>Sostituisce i segnaposto e restituisce un frammento di percorso già ripulito.</summary>
public static string Expand(string template, in NamingContext context, bool allowSeparators)
{
if (string.IsNullOrWhiteSpace(template)) return string.Empty;
var result = new StringBuilder(template.Length + 32);
int i = 0;
while (i < template.Length)
{
char c = template[i];
if (c != '{') { result.Append(c); i++; continue; }
int close = template.IndexOf('}', i + 1);
if (close < 0) { result.Append(c); i++; continue; }
string body = template[(i + 1)..close];
int colon = body.IndexOf(':');
string token = (colon < 0 ? body : body[..colon]).Trim().ToLowerInvariant();
string format = colon < 0 ? string.Empty : body[(colon + 1)..].Trim();
result.Append(Sanitize(Resolve(token, format, context), allowSeparators));
i = close + 1;
}
return Tidy(result.ToString(), allowSeparators);
}
private static string Resolve(string token, string format, in NamingContext context)
{
var metadata = context.Metadata;
var capture = metadata.CaptureTime ?? DateTime.Now;
var invariant = CultureInfo.InvariantCulture;
return token switch
{
"anno" => capture.ToString("yyyy", invariant),
"mese" => capture.ToString("MM", invariant),
"giorno" => capture.ToString("dd", invariant),
"data" => capture.ToString(format.Length > 0 ? format : "yyyy-MM-dd", invariant),
"ora" => capture.ToString(format.Length > 0 ? format : "HH-mm-ss", invariant),
"oraminuti" => capture.ToString("HH-mm", invariant),
"sessione" => (context.SessionIndex + 1).ToString(format.Length > 0 ? format : "00", invariant),
"iniziosessione" => context.SessionStart.ToString(
format.Length > 0 ? format : "yyyy-MM-dd HH-mm", invariant),
"fotocamera" => metadata.Camera ?? "fotocamera",
"obiettivo" => metadata.Lens ?? "obiettivo",
"iso" => metadata.Iso?.ToString(invariant) ?? "iso",
"posa" => metadata.ExposureText,
"diaframma" => metadata.FNumber is { } aperture
? "f" + aperture.ToString("0.#", invariant)
: "f",
"n" => (context.Index + 1).ToString(format.Length > 0 ? format : "0000", invariant),
"nsessione" => (context.SessionCount + 1).ToString(format.Length > 0 ? format : "0000", invariant),
"nome" => Path.GetFileNameWithoutExtension(metadata.FileName),
"estensione" => Path.GetExtension(metadata.FileName).TrimStart('.'),
_ => string.Empty,
};
}
/// <summary>
/// Toglie i caratteri che il file system rifiuta. Il separatore sopravvive solo nei
/// modelli di cartella, dove dividere i livelli è il senso stesso del modello.
/// </summary>
private static string Sanitize(string value, bool allowSeparators)
{
if (value.Length == 0) return value;
var invalid = Path.GetInvalidFileNameChars();
var builder = new StringBuilder(value.Length);
foreach (char c in value)
{
bool isSeparator = c == '/' || c == Path.DirectorySeparatorChar;
if (isSeparator)
{
builder.Append(allowSeparators ? Path.DirectorySeparatorChar : '-');
continue;
}
builder.Append(Array.IndexOf(invalid, c) >= 0 ? '-' : c);
}
return builder.ToString();
}
/// <summary>Compatta separatori ripetuti e spazi ai bordi di ogni livello.</summary>
private static string Tidy(string value, bool allowSeparators)
{
if (!allowSeparators) return value.Trim().Trim('.', ' ');
var parts = value.Split(['/', Path.DirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)
.Select(part => part.Trim().Trim('.', ' '))
.Where(part => part.Length > 0);
return string.Join(Path.DirectorySeparatorChar, parts);
}
/// <summary>
/// Verifica che un modello sia utilizzabile e spiega il primo problema trovato.
/// Restituisce null quando va bene.
/// </summary>
public static string? Validate(string template, bool isFolder)
{
if (string.IsNullOrWhiteSpace(template))
return isFolder ? null : "Il modello del nome file non può essere vuoto.";
int depth = 0;
foreach (char c in template)
{
if (c == '{') depth++;
else if (c == '}') depth--;
if (depth is < 0 or > 1) return "Le parentesi graffe non sono bilanciate.";
}
if (depth != 0) return "Manca una parentesi graffa di chiusura.";
var known = Tokens.Select(t => t.Token).ToHashSet();
int i = 0;
while (i < template.Length)
{
if (template[i] != '{') { i++; continue; }
int close = template.IndexOf('}', i + 1);
if (close < 0) break;
string body = template[(i + 1)..close];
int colon = body.IndexOf(':');
string token = (colon < 0 ? body : body[..colon]).Trim().ToLowerInvariant();
if (!known.Contains(token)) return "Segnaposto sconosciuto: " + token;
i = close + 1;
}
if (!isFolder && (template.Contains('/') || template.Contains(Path.DirectorySeparatorChar)))
return "Il nome del file non può contenere separatori di cartella.";
return null;
}
}
+32 -5
View File
@@ -123,7 +123,8 @@ public sealed class TitanoProject
public int EffectiveOrientation => General.OrientationOverride ?? Orientation.Orientation;
/// <summary>Vero se serve lo stadio geometrico finale, quindi un ricampionamento in più.</summary>
public bool NeedsGeometry => Camera.Enabled || Stabilization.Enabled;
public bool NeedsGeometry => Camera.Enabled || Stabilization.Enabled || Export.CropEnabled ||
Export.AspectRatio > 0.01;
/// <summary>
/// Esamina il primo fotogramma per stabilire come vanno raddrizzati i pixel. Va invocato
@@ -267,7 +268,10 @@ public sealed class TitanoProject
: General.WorkingWidth > 0 ? Math.Min(General.WorkingWidth, sourceWidth)
: sourceWidth;
double aspect = sourceHeight / (double)sourceWidth;
// Il rapporto scelto governa l'altezza; senza scelta si conserva quello della sorgente.
double aspect = Export.AspectRatio > 0.01
? 1.0 / Export.AspectRatio
: sourceHeight / (double)sourceWidth;
int targetHeight = Export.Height > 0 ? Export.Height : (int)Math.Round(targetWidth * aspect);
targetWidth = Math.Max(2, targetWidth & ~1);
@@ -314,10 +318,33 @@ public sealed class TitanoProject
}
}
/// <summary>Inquadratura effettiva a un dato punto della sequenza, vincoli compresi.</summary>
/// <summary>
/// Inquadratura effettiva a un dato punto della sequenza, vincoli compresi.
///
/// Il movimento virtuale, quando è attivo, ha la precedenza sul ritaglio fisso: sono due
/// modi di dire la stessa cosa, e averli entrambi in funzione significherebbe ritagliare
/// due volte. Il ritaglio fisso resta per chi vuole solo scegliere un'inquadratura.
/// </summary>
public CameraFraming FramingAt(double normalizedTime)
{
var framing = Camera.Enabled ? VirtualCamera.Resolve(Camera, normalizedTime) : CameraFraming.Full;
return VirtualCamera.Constrain(framing, Camera.KeepInsideFrame, StabilizationZoom);
var framing = Camera.Enabled
? VirtualCamera.Resolve(Camera, normalizedTime)
: Export.CropEnabled
? new CameraFraming(Export.CropCentreX, Export.CropCentreY, Export.CropZoom)
: CameraFraming.Full;
bool keepInside = Camera.Enabled ? Camera.KeepInsideFrame : true;
return VirtualCamera.Constrain(framing, keepInside, StabilizationZoom);
}
/// <summary>Rapporto larghezza/altezza dell'uscita: quello scelto, o quello della sorgente.</summary>
public double OutputAspect
{
get
{
if (Export.AspectRatio > 0.01) return Export.AspectRatio;
var (width, height) = ResolveNativeSize();
return width > 0 && height > 0 ? width / (double)height : 16.0 / 9.0;
}
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ internal static class Program
Pump(2500); // attesa del rendering asincrono dell'anteprima
}
if (settingsTab > 0)
if (settingsTab >= 0)
{
form.SelectSettingsTab(settingsTab);
Pump(200);
+61 -3
View File
@@ -45,11 +45,11 @@ dell'interfaccia riceve solo aggiornamenti di stato immutabili tramite `IProgres
```
Metadata/ parser binario TIFF/Exif, scanner XMP, riconoscimento contenitori
Core/ sequenza, cadenza, shutter angle, spline monotona e Bézier
Imaging/ buffer poolati, spazio colore lineare, decodifica WIC, stadio geometrico
Imaging/ buffer poolati, spazio colore lineare, decodifica WIC, geometria, scrittura TIFF/DNG
Analysis/ luminanza, deflicker, segmentazione in regioni, transizioni giorno-notte
Motion/ Fourier, correlazione di fase, stabilizzazione, optical flow, blur, stacking
Video/ conversione NV12, encoder Media Foundation, multiplexer MP4
Pipeline/ piano di rendering, finestra scorrevole, direttore automatico, preferenze
Pipeline/ piano di rendering, finestra scorrevole, direttore automatico, importazione
UI/ tema scuro, navigazione, strumenti di misura, editor di curve e keyframe
Diagnostics/ sequenze sintetiche, verifica end-to-end, ispettore MP4
```
@@ -82,6 +82,52 @@ gli avvisi sulla sequenza, istogramma e forma d'onda sull'esposizione, il percor
della stabilizzazione sul movimento, il piano temporale sul tempo. Tutte grandezze che il
motore già calcolava e che finivano in due numeri in fondo a una riga di stato.
## Archivio e importazione
La scheda Archivio scarica una scheda di memoria e la mette a posto. Riconosce i supporti
collegati — quelli rimovibili e quelli che contengono una cartella DCIM — cerca nelle
sottocartelle, e prima di toccare il disco mostra il piano: quali cartelle verranno create e
che nome avranno i primi file. Un'importazione sbagliata su mille file non si annulla, e
vedere prima costa un istante.
Fa tre cose che a mano si sbagliano sempre.
**Riconosce le sessioni.** Una scheda contiene di norma più riprese; separarle sulla pausa fra
due scatti evita di doverle distinguere dopo guardando gli orari. I gruppi troppo piccoli
confluiscono nel precedente invece di generare una cartella propria: quasi sempre sono gli
scatti di prova fatti prima di cominciare.
**Costruisce i nomi da una regola.** Cartelle e file nascono da modelli con segnaposto —
`{anno}/{data} {fotocamera}` e `{data}_{ora}_{n:0000}` — perché un archivio si consulta anni
dopo e il nome è l'unica cosa leggibile senza aprire nulla. I caratteri che il file system
rifiuta vengono sostituiti, mai lasciati passare.
**Verifica quello che ha scritto.** Dopo la copia rilegge entrambi i file e ne confronta
l'impronta. Non è zelo: una scheda che si scollega a metà copia produce file della dimensione
giusta e del contenuto troncato, e il danno si scopre mesi dopo, quando l'originale non c'è
più.
### Sulla conversione in DNG
La copia resta l'impostazione predefinita, ed è quasi sempre la scelta giusta: conserva il
grezzo, quindi la libertà di rifare lo sviluppo.
Va detto con precisione cosa il programma può e non può fare. Un DNG *vero* contiene i valori
del sensore prima dell'interpolazione cromatica, ed è quello che permette di rifare da capo
bilanciamento del bianco e demosaicizzazione. Ottenerlo da un formato proprietario richiede di
interpretare quel sensore modello per modello, cioè la libreria del produttore — che il
vincolo sulle dipendenze esclude.
Quello che si può fare in-house è un **DNG lineare**: la specifica lo prevede
(PhotometricInterpretation 34892), contiene pixel già interpolati a 16 bit in luce lineare, ed
è un file valido e apribile ovunque. Non restituisce però la libertà del grezzo. Insieme a
TIFF 16 bit e JPEG serve a consegnare qualcosa a un altro programma, non ad archiviare.
Il contenitore è scritto a mano, come il multiplexer MP4. La verifica automatica lo rilegge
con il parser TIFF di questo stesso programma: sono due implementazioni indipendenti dello
stesso formato, e alla prima esecuzione il confronto ha trovato un errore di dodici byte per
voce nel calcolo degli scostamenti.
## Il pilota automatico
I parametri che si possono dedurre dalle misure non si chiedono. Un cursore con l'indicatore
@@ -100,6 +146,18 @@ il menu dell'orientamento usava da solo, esteso a tutti i parametri deducibili.
Vale una regola sopra tutte: dove il valore giusto non si può misurare, il direttore non
inventa — restituisce meno decisioni e lascia il cursore dov'è.
Ogni sezione ha inoltre un comando **Ottimizza** che rileva le impostazioni migliori per
quella sola parte e dice cosa ha cambiato e perché. Si distingue dal pilota automatico, che
lavora di continuo sui parametri deducibili senza ambiguità: l'ottimizzazione si chiede a mano
perché prende decisioni più impegnative, comprese quelle di accendere o spegnere interi
moduli, e sostituirle senza dirlo sarebbe peggio che lasciarle sbagliate. Dove il dato manca —
la sequenza non è ancora stata analizzata — non tira a indovinare: lo dice.
Le spiegazioni non stanno sotto ai controlli ma nei suggerimenti che compaiono passandoci
sopra. Una nota stampata occupa spazio a chi la conosce già, e per questo deve restare corta;
un suggerimento che compare solo quando serve può dire l'unica cosa che conta davvero — perché
quel parametro esiste, e cosa succede a spostarlo nella direzione sbagliata.
## Il riquadro sponsor
La codifica di una sequenza lunga sono minuti o decine di minuti in cui non c'è niente da
@@ -344,7 +402,7 @@ Titano.exe --selftest [cartella]
```
Genera sequenze sintetiche dalle proprietà note e le fa attraversare l'intera pipeline,
confrontando 54 grandezze misurate con i valori attesi. Nessuna soglia è scelta a posteriori:
confrontando 60 grandezze misurate con i valori attesi. Nessuna soglia è scelta a posteriori:
la scena è costruita perché il valore atteso sia la conseguenza aritmetica di come è stata
generata — il tremolio ha un percorso noto, il gradino di esposizione un'ampiezza dichiarata
nei metadati e visibile nei pixel, la nuvola attraversa il solo cielo.
+215
View File
@@ -0,0 +1,215 @@
using Titano.Video;
namespace Titano.UI;
/// <summary>
/// Scelta dell'area di uscita dentro il fotogramma sorgente: si trascina il rettangolo per
/// spostarlo e la maniglia d'angolo per stringerlo.
///
/// Il rettangolo ha sempre il rapporto scelto per l'uscita, non quello della sorgente. È la
/// differenza fra tagliare e deformare: passando da 4:3 a 16:9 quello che si perde sono due
/// fasce, e deciderle guardando l'immagine è l'unico modo sensato di farlo.
/// </summary>
internal sealed class FramingEditor : Control
{
private const float HandleSize = 10f;
private readonly ExportSettings _export;
private bool _dragging;
private bool _draggingZoom;
private PointF _grabOffset;
public event EventHandler? Changed;
/// <summary>Rapporto larghezza/altezza del fotogramma sorgente.</summary>
public double SourceAspect { get; set; } = 4.0 / 3.0;
/// <summary>Rapporto richiesto per l'uscita.</summary>
public double OutputAspect { get; set; } = 4.0 / 3.0;
public FramingEditor(ExportSettings export)
{
_export = export;
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
Height = 172;
}
/// <summary>Riquadro che rappresenta il fotogramma sorgente, con le sue proporzioni.</summary>
private RectangleF Stage
{
get
{
var available = new RectangleF(10, 22, Math.Max(20, Width - 20), Math.Max(20, Height - 44));
float aspect = (float)Math.Max(0.1, SourceAspect);
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);
}
}
/// <summary>Rettangolo di ritaglio, con il rapporto dell'uscita e vincolato dentro la sorgente.</summary>
private RectangleF CropRect
{
get
{
var stage = Stage;
double zoom = Math.Max(1.0, _export.CropZoom);
// Il più grande rettangolo con il rapporto dell'uscita che sta nella sorgente,
// poi ristretto dallo zoom: la stessa regola che applica lo stadio geometrico.
double baseWidth = Math.Min(stage.Width, stage.Height * OutputAspect);
double baseHeight = baseWidth / Math.Max(1e-6, OutputAspect);
float width = (float)(baseWidth / zoom);
float height = (float)(baseHeight / zoom);
float halfX = width / 2f / stage.Width;
float halfY = height / 2f / stage.Height;
float centreX = (float)Math.Clamp(_export.CropCentreX, halfX, 1 - halfX);
float centreY = (float)Math.Clamp(_export.CropCentreY, halfY, 1 - halfY);
return new RectangleF(stage.Left + centreX * stage.Width - width / 2,
stage.Top + centreY * stage.Height - height / 2, width, height);
}
}
private RectangleF Handle
{
get
{
var crop = CropRect;
return new RectangleF(crop.Right - HandleSize, crop.Bottom - HandleSize,
HandleSize * 2, HandleSize * 2);
}
}
// ------------------------------------------------------------------ interazione
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button != MouseButtons.Left) return;
Focus();
if (Handle.Contains(e.Location)) { _draggingZoom = true; return; }
var crop = CropRect;
if (!crop.Contains(e.Location)) return;
_dragging = true;
_grabOffset = new PointF(e.X - (crop.Left + crop.Width / 2), e.Y - (crop.Top + crop.Height / 2));
}
protected override void OnMouseMove(MouseEventArgs e)
{
var stage = Stage;
if (_draggingZoom)
{
float halfWidth = Math.Max(6f, e.X - (stage.Left + (float)_export.CropCentreX * stage.Width));
double baseWidth = Math.Min(stage.Width, stage.Height * OutputAspect);
_export.CropZoom = Math.Clamp(baseWidth / (2.0 * halfWidth), 1.0, 6.0);
Changed?.Invoke(this, EventArgs.Empty);
Invalidate();
return;
}
if (_dragging)
{
_export.CropCentreX = Math.Clamp((e.X - _grabOffset.X - stage.Left) / stage.Width, 0, 1);
_export.CropCentreY = Math.Clamp((e.Y - _grabOffset.Y - stage.Top) / stage.Height, 0, 1);
Changed?.Invoke(this, EventArgs.Empty);
Invalidate();
return;
}
Cursor = Handle.Contains(e.Location) ? Cursors.SizeNWSE
: CropRect.Contains(e.Location) ? Cursors.SizeAll
: Cursors.Default;
base.OnMouseMove(e);
}
protected override void OnMouseUp(MouseEventArgs e)
{
_dragging = false;
_draggingZoom = false;
base.OnMouseUp(e);
}
protected override void OnMouseDoubleClick(MouseEventArgs e)
{
// Doppio clic: si torna all'inquadratura piena, che è la richiesta più frequente
// dopo aver provato un ritaglio e non esserne convinti.
_export.CropCentreX = 0.5;
_export.CropCentreY = 0.5;
_export.CropZoom = 1.0;
Changed?.Invoke(this, EventArgs.Empty);
Invalidate();
base.OnMouseDoubleClick(e);
}
// ------------------------------------------------------------------ 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, 3f, Theme.SurfaceAlt);
using (var border = new Pen(Theme.Border)) g.DrawRectangle(border, Rectangle.Round(stage));
var crop = CropRect;
// Le fasce escluse si scuriscono: si vede subito quanto si sta buttando via.
using (var shade = new SolidBrush(Color.FromArgb(150, Theme.Background)))
{
g.FillRectangle(shade, stage.Left, stage.Top, stage.Width, crop.Top - stage.Top);
g.FillRectangle(shade, stage.Left, crop.Bottom, stage.Width, stage.Bottom - crop.Bottom);
g.FillRectangle(shade, stage.Left, crop.Top, crop.Left - stage.Left, crop.Height);
g.FillRectangle(shade, crop.Right, crop.Top, stage.Right - crop.Right, crop.Height);
}
using (var pen = new Pen(Theme.Accent, 2f)) g.DrawRectangle(pen, crop.Left, crop.Top, crop.Width, crop.Height);
using (var handle = new SolidBrush(Theme.Accent))
{
g.FillRectangle(handle, crop.Right - HandleSize / 2, crop.Bottom - HandleSize / 2, HandleSize, HandleSize);
}
// Terzi dentro il ritaglio: servono a comporre, ed è per comporre che si sta qui.
using (var thirds = new Pen(Color.FromArgb(70, Color.White)))
{
for (int i = 1; i < 3; i++)
{
float x = crop.Left + crop.Width * i / 3f;
float y = crop.Top + crop.Height * i / 3f;
g.DrawLine(thirds, x, crop.Top, x, crop.Bottom);
g.DrawLine(thirds, crop.Left, y, crop.Right, y);
}
}
TextRenderer.DrawText(g, "AREA DI USCITA", Theme.SmallBold, new Rectangle(2, 2, 200, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
double coverage = stage.Width * stage.Height > 0
? crop.Width * crop.Height / (stage.Width * stage.Height)
: 1;
TextRenderer.DrawText(g, $"{_export.CropZoom:0.00}× · {coverage * 100:0}% dell'area sorgente",
Theme.Small, new Rectangle(Width - 260, 2, 252, 16), Theme.TextMuted,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, "trascina per spostare · angolo per stringere · doppio clic azzera",
Theme.Small, new Rectangle(10, Height - 18, Width - 20, 16), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
}
}
+342
View File
@@ -0,0 +1,342 @@
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>
/// Pagina dell'archivio: da dove si importa, cosa verrà scritto e dove.
///
/// L'anteprima del piano non è un vezzo. Un'importazione sbagliata su mille file non si
/// annulla: o si è scritto nel posto giusto con il nome giusto, o si passa la serata a
/// rimettere in ordine. Vedere prima le cartelle che verranno create e i primi nomi che ne
/// escono costa un istante e toglie l'unico rischio serio dell'operazione.
/// </summary>
internal sealed class ImportPanel : Panel
{
private readonly ImportSettings _settings;
private readonly VolumeList _volumes = new() { Dock = DockStyle.Top, Height = 148 };
private readonly PlanView _plan = new() { Dock = DockStyle.Fill };
private readonly ProgressStrip _progress = new() { Dock = DockStyle.Bottom, Height = 78 };
private List<ImportCandidate> _candidates = [];
private ImportPlan? _current;
/// <summary>L'utente ha scelto un supporto: il chiamante aggiorna la sorgente e rilegge.</summary>
public event EventHandler<string>? VolumeChosen;
public ImportPanel(ImportSettings settings)
{
_settings = settings;
BackColor = Theme.Background;
Padding = new Padding(14, 12, 14, 12);
_volumes.VolumeChosen += (_, path) => VolumeChosen?.Invoke(this, path);
Controls.Add(_plan);
Controls.Add(_progress);
Controls.Add(_volumes);
}
public ImportPlan? CurrentPlan => _current;
public int CandidateCount => _candidates.Count;
/// <summary>Rilegge l'elenco dei supporti collegati.</summary>
public void Refresh()
{
_volumes.SetVolumes(MediaImporter.Volumes(), _settings.SourcePath);
_plan.Update(_settings, _candidates, _current);
}
public void SetScan(List<ImportCandidate> candidates, ImportPlan? plan)
{
_candidates = candidates;
_current = plan;
_plan.Update(_settings, candidates, plan);
}
public void RebuildPlan()
{
_current = _candidates.Count > 0 ? MediaImporter.Plan(_candidates, _settings) : null;
_plan.Update(_settings, _candidates, _current);
}
public void ReportProgress(PipelineProgress progress) => _progress.Report(progress);
public void ShowResult(ImportResult result) => _progress.Show(result);
public void ShowMessage(string message) => _progress.ShowMessage(message);
// ================================================================== supporti
private sealed class VolumeList : Control
{
private List<ImportVolume> _items = [];
private string _selected = string.Empty;
private int _hovered = -1;
public event EventHandler<string>? VolumeChosen;
public VolumeList()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
Cursor = Cursors.Hand;
}
public void SetVolumes(List<ImportVolume> volumes, string selected)
{
_items = volumes;
_selected = selected;
Invalidate();
}
private const int RowHeight = 30;
private Rectangle Body => new(0, 26, Width, Math.Max(20, Height - 32));
private int IndexAt(int y)
{
int index = (y - Body.Top) / RowHeight;
return index >= 0 && index < _items.Count ? index : -1;
}
protected override void OnMouseMove(MouseEventArgs e)
{
int index = IndexAt(e.Y);
if (index != _hovered) { _hovered = index; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hovered = -1;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
int index = IndexAt(e.Y);
if (index < 0) return;
_selected = _items[index].Path;
Invalidate();
VolumeChosen?.Invoke(this, _items[index].Path);
base.OnMouseDown(e);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
TextRenderer.DrawText(g, "SUPPORTI COLLEGATI", Theme.SmallBold, new Rectangle(2, 4, 300, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
if (_items.Count == 0)
{
TextRenderer.DrawText(g, "Nessun supporto rilevato. Indica una cartella dalle impostazioni.",
Theme.Small, Body, Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
return;
}
var body = Body;
for (int i = 0; i < _items.Count; i++)
{
var volume = _items[i];
var row = new Rectangle(0, body.Top + i * RowHeight, Width, RowHeight - 2);
if (row.Bottom > body.Bottom) break;
bool chosen = string.Equals(volume.Path, _selected, StringComparison.OrdinalIgnoreCase);
if (chosen) Theme.FillRounded(g, row, 4f, Theme.SurfaceAlt);
else if (i == _hovered) Theme.FillRounded(g, row, 4f, Theme.Surface);
// Il pallino distingue a colpo d'occhio una scheda da un disco interno: sono
// due cose diverse e si sbaglia facilmente a scaricare dalla seconda.
using (var dot = new SolidBrush(volume.Removable ? Theme.Accent : Theme.TextFaint))
g.FillEllipse(dot, 8, row.Top + RowHeight / 2f - 4, 7, 7);
TextRenderer.DrawText(g, volume.Description, chosen ? Theme.BodyBold : Theme.Body,
new Rectangle(24, row.Top, Width - 200, row.Height),
chosen ? Theme.Text : Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
string free = $"{volume.FreeBytes / (1024.0 * 1024 * 1024):0.#} GB liberi";
TextRenderer.DrawText(g, free, Theme.Small, new Rectangle(Width - 190, row.Top, 182, row.Height),
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
}
}
// ================================================================== piano
private sealed class PlanView : Control
{
private readonly List<string> _lines = [];
private string _headline = "Nessuna scansione eseguita";
private string _detail = "Scegli un supporto e premi «Leggi supporto» per vedere cosa contiene.";
private bool _warning;
public PlanView()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
}
public void Update(ImportSettings settings, List<ImportCandidate> candidates, ImportPlan? plan)
{
_lines.Clear();
_warning = false;
if (candidates.Count == 0)
{
_headline = "Nessuna scansione eseguita";
_detail = "Scegli un supporto e premi «Leggi supporto» per vedere cosa contiene.";
Invalidate();
return;
}
if (plan is null)
{
_headline = $"{candidates.Count} file trovati";
_detail = "Imposta la destinazione per costruire il piano.";
Invalidate();
return;
}
string size = plan.TotalBytes >= 1L << 30
? $"{plan.TotalBytes / (1024.0 * 1024 * 1024):0.0} GB"
: $"{plan.TotalBytes / (1024.0 * 1024):0} MB";
_headline = $"{plan.Pending} da importare · {plan.Skipped} già presenti · {size}";
_detail = plan.SessionCount > 1
? $"{plan.SessionCount} sessioni riconosciute con pause oltre {settings.SessionGapMinutes:0} minuti"
: "Una sola sessione riconosciuta";
if (string.IsNullOrWhiteSpace(settings.DestinationRoot))
{
_warning = true;
_detail = "Manca la cartella di destinazione.";
}
foreach (string folder in plan.Folders.Take(6))
{
_lines.Add("cartella " + folder);
}
foreach (var step in plan.Steps.Where(s => !s.Skipped).Take(6))
{
_lines.Add("file " + Path.GetFileName(step.DestinationPath));
}
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
var card = new RectangleF(0.5f, 4.5f, Width - 1, Math.Max(40, Height - 12));
Theme.FillAndStroke(g, card, 6f, Theme.Surface, _warning ? Theme.Warning : Theme.Border);
TextRenderer.DrawText(g, "COSA VERRÀ IMPORTATO", Theme.SmallBold, new Rectangle(16, 14, 300, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, _headline, Theme.BodyBold, new Rectangle(16, 34, Width - 32, 22),
_warning ? Theme.Warning : Theme.Text,
TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
TextRenderer.DrawText(g, _detail, Theme.Small, new Rectangle(16, 58, Width - 32, 18),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
int y = 86;
foreach (string line in _lines)
{
if (y > Height - 30) break;
TextRenderer.DrawText(g, line, Theme.Small, new Rectangle(16, y, Width - 32, 18),
Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.PathEllipsis);
y += 18;
}
}
}
// ================================================================== avanzamento
private sealed class ProgressStrip : Control
{
private PipelineProgress _progress;
private string _message = string.Empty;
private bool _failed;
public ProgressStrip()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
}
public void Report(PipelineProgress progress)
{
_progress = progress;
_message = string.Empty;
Invalidate();
}
public void ShowMessage(string message)
{
_message = message;
_failed = false;
Invalidate();
}
public void Show(ImportResult result)
{
_failed = result.Failed > 0;
_message = $"Importati {result.Imported} file in {result.Elapsed.TotalSeconds:0.0} s" +
(result.Skipped > 0 ? $", {result.Skipped} già presenti" : string.Empty) +
(result.Failed > 0 ? $", {result.Failed} non riusciti" : string.Empty) +
(result.Errors.Count > 0 ? " — " + result.Errors[0] : string.Empty);
_progress = default;
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
var card = new RectangleF(0.5f, 6.5f, Width - 1, Height - 10);
Theme.FillAndStroke(g, card, 6f, Theme.Surface, _failed ? Theme.Danger : Theme.Border);
if (_message.Length > 0)
{
TextRenderer.DrawText(g, _message, Theme.Body, new Rectangle(16, 6, Width - 32, Height - 12),
_failed ? Theme.Danger : Theme.Success,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
return;
}
if (_progress.Total <= 0)
{
TextRenderer.DrawText(g, "In attesa", Theme.Small, new Rectangle(16, 6, Width - 32, Height - 12),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
return;
}
TextRenderer.DrawText(g, $"{_progress.Message} {_progress.Completed}/{_progress.Total}",
Theme.Body, new Rectangle(16, 14, Width - 32, 20), Theme.Text,
TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
var bar = new RectangleF(16, 46, Width - 32, 8);
Theme.FillRounded(g, bar, 4f, Theme.SurfaceAlt);
if (_progress.Fraction > 0.0005)
{
Theme.FillRounded(g, new RectangleF(bar.X, bar.Y, (float)(bar.Width * _progress.Fraction), bar.Height),
4f, Theme.Accent);
}
}
}
}
+214 -36
View File
@@ -30,10 +30,14 @@ internal sealed class MainForm : Form
private readonly MotionPathChart _motionPath = new() { Dock = DockStyle.Fill };
private readonly PlanChart _planChart = new() { Dock = DockStyle.Fill };
private readonly ExportPanel _export;
private readonly ImportPanel _import;
private readonly AboutPanel _about = new() { Dock = DockStyle.Fill };
private readonly ImportSettings _importSettings = new();
private readonly Dictionary<WorkspaceSection, Control> _sections = [];
private Panel _sectionHost = null!;
private Panel _previewHost = null!;
private Splitter _previewSplitter = null!;
private readonly DarkProgressBar _progress = new() { Dock = DockStyle.Fill };
private readonly Label _status;
@@ -45,6 +49,8 @@ internal sealed class MainForm : Form
private readonly DarkButton _analyzeButton = new() { Text = "Analizza sequenza", Width = 158 };
private readonly DarkButton _exportButton = new() { Text = "Esporta video", Width = 136, Primary = true };
private readonly DarkButton _cancelButton = new() { Text = "Annulla", Width = 92, Danger = true, Visible = false };
private readonly DarkButton _importButton = new() { Text = "Leggi supporto", Width = 138, Primary = true };
private readonly DarkButton _optimizeButton = new() { Text = "Ottimizza", Width = 178 };
private readonly DarkToggle _compareToggle = new() { Glyph = ToggleGlyph.Compare, Hint = "Confronto prima/dopo" };
private readonly DarkToggle _zoomToggle = new() { Glyph = ToggleGlyph.Zoom, Hint = "Scala uno a uno" };
@@ -56,7 +62,7 @@ internal sealed class MainForm : Form
public MainForm()
{
Text = "Titano — time-lapse";
Text = "Titano";
MinimumSize = new Size(1280, 760);
Size = new Size(1660, 980);
StartPosition = FormStartPosition.CenterScreen;
@@ -68,9 +74,15 @@ internal sealed class MainForm : Form
_app.ApplyTo(_project);
// Una destinazione plausibile evita che la prima importazione fallisca solo perché
// nessuno ha ancora scelto dove mettere le cose.
_importSettings.DestinationRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), "Titano");
_preview = new PreviewPanel(_project) { Dock = DockStyle.Fill };
_settings = new SettingsPanel(_project, _app) { Dock = DockStyle.Fill };
_settings = new SettingsPanel(_project, _app, _importSettings) { Dock = DockStyle.Fill };
_export = new ExportPanel(_project) { Dock = DockStyle.Fill };
_import = new ImportPanel(_importSettings) { Dock = DockStyle.Fill };
_status = new Label
{
@@ -91,9 +103,14 @@ internal sealed class MainForm : Form
LoadApplicationIcon();
BuildLayout();
// La barra nasce sulla prima voce, che è l'archivio: portarla su Sequenza prima di
// collegare gli eventi evita che il selettore esca subito perché l'indice coincide,
// lasciando la barra su una sezione e il contenuto su un'altra.
_rail.Selected = WorkspaceSection.Sequence;
WireEvents();
_export.Configure(_app);
ShowSection(WorkspaceSection.Sequence);
ShowSection(_rail.Selected);
UpdateCommandState();
}
@@ -156,6 +173,7 @@ internal sealed class MainForm : Form
_sections[WorkspaceSection.Exposure] = exposurePage;
_sections[WorkspaceSection.Motion] = Card(_motionPath, "Percorso della stabilizzazione", DockStyle.Fill);
_sections[WorkspaceSection.Timing] = Card(_planChart, "Piano temporale", DockStyle.Fill);
_sections[WorkspaceSection.Archive] = _import;
_sections[WorkspaceSection.Export] = _export;
_sections[WorkspaceSection.Preferences] = _about;
@@ -167,13 +185,13 @@ internal sealed class MainForm : Form
_sectionHost.Controls.Add(section);
}
var previewHost = Card(_preview, "Anteprima", DockStyle.Top, 330, BuildPreviewTools());
var splitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
_previewHost = Card(_preview, "Anteprima", DockStyle.Top, 330, BuildPreviewTools());
_previewSplitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
var center = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Background };
center.Controls.Add(_sectionHost);
center.Controls.Add(splitter);
center.Controls.Add(previewHost);
center.Controls.Add(_previewSplitter);
center.Controls.Add(_previewHost);
Controls.Add(center);
Controls.Add(_settingsHost);
@@ -247,33 +265,8 @@ internal sealed class MainForm : Form
TextAlign = ContentAlignment.MiddleLeft,
};
var subtitle = new Label
{
Text = "elaborazione time-lapse",
Font = Theme.Small,
ForeColor = Theme.TextFaint,
AutoSize = false,
Bounds = new Rectangle(104, 20, 152, 18),
TextAlign = ContentAlignment.MiddleLeft,
};
int x = 272;
foreach (var button in new[] { _addFilesButton, _addFolderButton, _clearButton })
{
button.Bounds = new Rectangle(x, 13, button.Width, 32);
bar.Controls.Add(button);
x += button.Width + 8;
}
_analyzeButton.Bounds = new Rectangle(x + 16, 13, _analyzeButton.Width, 32);
bar.Controls.Add(_analyzeButton);
x += _analyzeButton.Width + 24;
_exportButton.Bounds = new Rectangle(x, 13, _exportButton.Width, 32);
bar.Controls.Add(_exportButton);
foreach (var button in ToolbarButtons) bar.Controls.Add(button);
bar.Controls.Add(title);
bar.Controls.Add(subtitle);
bar.Paint += (_, e) =>
{
@@ -283,6 +276,32 @@ internal sealed class MainForm : Form
return bar;
}
/// <summary>I pulsanti della barra, nell'ordine in cui compaiono quando sono visibili.</summary>
private IEnumerable<DarkButton> ToolbarButtons =>
[
_importButton, _addFilesButton, _addFolderButton, _clearButton, _analyzeButton,
_optimizeButton, _exportButton,
];
/// <summary>
/// Ridispone i pulsanti mostrati uno dopo l'altro. Con una barra contestuale le posizioni
/// fisse lascerebbero buchi dove i pulsanti nascosti stavano prima.
///
/// L'elenco arriva dal chiamante e non si legge da <c>Visible</c>: finché la finestra non
/// è stata mostrata quella proprietà restituisce falso anche per i controlli appena
/// accesi, perché riporta la visibilità effettiva dell'intera catena. Leggendola qui,
/// alla costruzione nessun pulsante verrebbe posizionato.
/// </summary>
private static void LayoutToolbar(IReadOnlyList<DarkButton> shown)
{
int x = 132;
foreach (var button in shown)
{
button.Bounds = new Rectangle(x, 13, button.Width, 32);
x += button.Width + 8;
}
}
private Panel BuildStatusBar()
{
var bar = new Panel { Dock = DockStyle.Bottom, Height = 52, BackColor = Theme.Background };
@@ -333,6 +352,15 @@ internal sealed class MainForm : Form
_rail.SelectionChanged += (_, _) => ShowSection(_rail.Selected);
_importButton.Click += async (_, _) => await ImportAsync();
_optimizeButton.Click += (_, _) => OptimizeCurrentSection();
_import.VolumeChosen += (_, path) =>
{
_importSettings.SourcePath = path;
_settings.ShowImportSource();
_import.Refresh();
};
_chart.SelectionChanged += (_, _) => Select(_chart.SelectedIndex);
_table.SelectionChanged += (_, _) => Select(_table.SelectedIndex);
_timeline.SelectionChanged += (_, _) => Select(_timeline.SelectedIndex);
@@ -382,6 +410,7 @@ internal sealed class MainForm : Form
_app.Save();
_export.Configure(_app);
};
_settings.ImportChanged += (_, _) => _import.RebuildPlan();
}
private void ShowSection(WorkspaceSection section)
@@ -390,8 +419,55 @@ internal sealed class MainForm : Form
_settings.ShowSection(section);
_export.SetActive(section == WorkspaceSection.Export);
// Le preferenze non riguardano la sequenza caricata: mostrarle accanto all'anteprima
// e al riepilogo degli scatti confonderebbe due piani diversi, e ruberebbe alla
// pagina lo spazio che le serve per essere letta senza scorrere.
bool sequenceContext = section is not (WorkspaceSection.Preferences or WorkspaceSection.Archive);
_previewHost.Visible = sequenceContext;
_previewSplitter.Visible = sequenceContext;
_summary.Visible = sequenceContext;
UpdateToolbarFor(section);
if (section == WorkspaceSection.Export) _export.Refresh(_project);
if (section == WorkspaceSection.Preferences) _about.Update(_project, _app);
if (section == WorkspaceSection.Archive) _import.Refresh();
if (section == WorkspaceSection.Preferences)
{
_about.Update(_project, _app);
// Anche la riga di stato parla della sequenza: qui non c'entra nulla.
SetStatus("Preferenze dell'applicazione. Valgono per ogni sequenza e restano salvate.");
}
}
/// <summary>
/// I comandi seguono la sezione. Un pulsante che non ha senso dove ci si trova non va
/// disabilitato ma tolto: disabilitato resta un ingombro che chiede perché non funziona.
/// </summary>
private void UpdateToolbarFor(WorkspaceSection section)
{
bool onSequence = section == WorkspaceSection.Sequence;
bool onArchive = section == WorkspaceSection.Archive;
var shown = new List<DarkButton>();
if (onArchive) shown.Add(_importButton);
if (onSequence) { shown.Add(_addFilesButton); shown.Add(_addFolderButton); shown.Add(_clearButton); }
if (onSequence) shown.Add(_analyzeButton);
if (section is not (WorkspaceSection.Preferences or WorkspaceSection.Archive)) shown.Add(_optimizeButton);
if (section == WorkspaceSection.Export) shown.Add(_exportButton);
foreach (var button in ToolbarButtons) button.Visible = shown.Contains(button);
_optimizeButton.Text = section switch
{
WorkspaceSection.Sequence => "Ottimizza lettura",
WorkspaceSection.Exposure => "Ottimizza esposizione",
WorkspaceSection.Motion => "Ottimizza movimento",
WorkspaceSection.Timing => "Ottimizza tempo",
_ => "Ottimizza esportazione",
};
LayoutToolbar(shown);
}
/// <summary>Allinea tutte le viste sullo stesso fotogramma, qualunque l'abbia scelto.</summary>
@@ -405,6 +481,108 @@ internal sealed class MainForm : Form
ShowPreview(index);
}
/// <summary>
/// Rilegge il supporto, costruisce il piano e — se la destinazione c'è — lo esegue.
/// Sono due passi distinti apposta: il primo non tocca il disco e si può guardare.
/// </summary>
private async Task ImportAsync()
{
if (_busy) return;
if (!Directory.Exists(_importSettings.SourcePath))
{
_import.ShowMessage("Scegli un supporto o una cartella di origine.");
return;
}
bool ready = _import.CandidateCount > 0 && _import.CurrentPlan is { Pending: > 0 };
BeginOperation(ready ? "Importazione…" : "Lettura del supporto…");
try
{
var progress = new Progress<PipelineProgress>(p =>
{
ReportProgress(p);
_import.ReportProgress(p);
});
if (!ready)
{
var settings = _importSettings.Clone();
var candidates = await Task.Run(
() => MediaImporter.Scan(settings, progress, _operation!.Token), _operation!.Token);
var plan = candidates.Count > 0 && !string.IsNullOrWhiteSpace(settings.DestinationRoot)
? MediaImporter.Plan(candidates, settings)
: null;
_import.SetScan(candidates, plan);
SetStatus(candidates.Count == 0
? "Nessun file d'immagine riconosciuto nel percorso indicato."
: $"{candidates.Count} file letti dal supporto. Controlla il piano e premi di nuovo per importare.");
return;
}
var current = _import.CurrentPlan!;
var executing = _importSettings.Clone();
var result = await Task.Run(
() => MediaImporter.Execute(current, executing, progress, _operation!.Token), _operation!.Token);
_import.ShowResult(result);
SetStatus($"Importati {result.Imported} file" +
(result.Skipped > 0 ? $", {result.Skipped} già presenti" : string.Empty) +
(result.Failed > 0 ? $", {result.Failed} non riusciti" : string.Empty) +
$" in {result.Elapsed.TotalSeconds:0.0} s.");
// La cartella appena riempita è quasi sempre quella che si vuole aprire subito.
if (result.Imported > 0 && result.FirstFolder is { } folder)
{
_app.LastFolder = folder;
_app.Save();
}
}
catch (OperationCanceledException)
{
_import.ShowMessage("Importazione interrotta.");
SetStatus("Importazione interrotta.");
}
catch (Exception ex)
{
_import.ShowMessage("Importazione non riuscita: " + ex.Message);
SetStatus("Importazione non riuscita: " + ex.Message);
}
finally
{
EndOperation();
}
}
/// <summary>Rileva le impostazioni ottimali per la sezione in vista e dice cosa ha cambiato.</summary>
private void OptimizeCurrentSection()
{
if (_busy) return;
var area = _rail.Selected switch
{
WorkspaceSection.Sequence => WorkspaceArea.Sequence,
WorkspaceSection.Exposure => WorkspaceArea.Exposure,
WorkspaceSection.Motion => WorkspaceArea.Motion,
WorkspaceSection.Timing => WorkspaceArea.Timing,
_ => WorkspaceArea.Export,
};
var changes = AutoOptimizer.Optimize(_project, area);
// I controlli leggono il progetto quando vengono costruiti: dopo un'ottimizzazione
// che tocca interruttori e menu, la colonna va rifatta o mostrerebbe i valori vecchi.
RebuildSettingsPanel();
RecomputeCurve();
RefreshDerived();
ShowPreview(_table.SelectedIndex);
SetStatus(string.Join(" ", changes));
}
// ------------------------------------------------------------------ comandi
private void AddFiles()
@@ -851,7 +1029,7 @@ internal sealed class MainForm : Form
// ------------------------------------------------------------------ modalità di cattura
internal void SelectSettingsTab(int index)
=> _rail.Selected = (WorkspaceSection)Math.Clamp(index, 0, 5);
=> _rail.Selected = (WorkspaceSection)Math.Clamp(index, 0, 6);
internal async Task PrepareForCaptureAsync(IReadOnlyList<string> paths)
{
@@ -894,7 +1072,7 @@ internal sealed class MainForm : Form
_settingsHost.Controls.Remove(_settings);
_settings.Dispose();
_settings = new SettingsPanel(_project, _app) { Dock = DockStyle.Fill };
_settings = new SettingsPanel(_project, _app, _importSettings) { Dock = DockStyle.Fill };
_settingsHost.Controls.Add(_settings);
WireSettingsEvents();
+16
View File
@@ -5,6 +5,7 @@ namespace Titano.UI;
/// <summary>Le sezioni in cui è divisa la finestra, nell'ordine in cui compaiono nella barra.</summary>
internal enum WorkspaceSection
{
Archive,
Sequence,
Exposure,
Motion,
@@ -32,6 +33,7 @@ internal sealed class NavigationRail : Control
private readonly Item[] _items =
[
new(WorkspaceSection.Archive, "Archivio", "Importazione da schede e fotocamere, nomi e cartelle"),
new(WorkspaceSection.Sequence, "Sequenza", "Fotogrammi, cadenza e lettura dei file"),
new(WorkspaceSection.Exposure, "Esposizione", "Deflicker, regioni e transizioni giorno-notte"),
new(WorkspaceSection.Motion, "Movimento", "Stabilizzazione, camera virtuale e sfocatura"),
@@ -198,6 +200,20 @@ internal sealed class NavigationRail : Control
switch (section)
{
case WorkspaceSection.Archive:
// Cartella con la linguetta: l'archivio, non un supporto specifico.
g.DrawLines(pen,
[
new PointF(x + 1, y + h - 1.5f),
new PointF(x + 1, y + 3.5f),
new PointF(x + w * 0.42f, y + 3.5f),
new PointF(x + w * 0.55f, y + 6.5f),
new PointF(x + w - 1, y + 6.5f),
new PointF(x + w - 1, y + h - 1.5f),
]);
g.DrawLine(pen, x + 1, y + h - 1.5f, x + w - 1, y + h - 1.5f);
break;
case WorkspaceSection.Sequence:
// Tre fotogrammi impilati e sfalsati.
g.DrawRectangle(pen, x + 0.5f, y + 5.5f, w - 6, h - 8);
+629 -152
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
using System.Drawing.Drawing2D;
namespace Titano.UI;
/// <summary>
/// Suggerimenti a comparsa, disegnati a mano nel tema scuro.
///
/// Le spiegazioni stanno qui invece che sotto ai controlli per un motivo pratico: una nota
/// stampata sotto un cursore deve essere corta, perché occupa spazio a chi la conosce già.
/// Un suggerimento che compare solo quando serve non ha quel vincolo, e può permettersi di
/// dire l'unica cosa che conta davvero — perché quel parametro esiste, e cosa succede se lo
/// si sposta nella direzione sbagliata.
///
/// Il suggerimento di sistema è disegnato dal tema di Windows, che qui sarebbe chiaro su
/// scuro: viene quindi disegnato per intero, con la stessa tavolozza del resto.
/// </summary>
internal static class Tips
{
private const int MaximumWidth = 380;
private static readonly ToolTip Instance = Create();
private static ToolTip Create()
{
var tip = new ToolTip
{
OwnerDraw = true,
InitialDelay = 380,
ReshowDelay = 120,
AutoPopDelay = 32000, // le spiegazioni lunghe devono restare leggibili
ShowAlways = true,
UseAnimation = false,
UseFading = false,
};
tip.Popup += (_, e) =>
{
var size = TextRenderer.MeasureText(tip.GetToolTip(e.AssociatedControl) ?? string.Empty,
Theme.Small, new Size(MaximumWidth, 0),
TextFormatFlags.WordBreak);
e.ToolTipSize = new Size(Math.Min(MaximumWidth, size.Width) + 22, size.Height + 18);
};
tip.Draw += (_, e) =>
{
var g = e.Graphics;
Theme.HighQuality(g);
g.SmoothingMode = SmoothingMode.AntiAlias;
var bounds = new RectangleF(0.5f, 0.5f, e.Bounds.Width - 1, e.Bounds.Height - 1);
Theme.FillAndStroke(g, bounds, 5f, Theme.SurfaceAlt, Theme.BorderStrong);
TextRenderer.DrawText(g, e.ToolTipText, Theme.Small,
Rectangle.Inflate(e.Bounds, -11, -9), Theme.Text,
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
};
return tip;
}
/// <summary>Associa una spiegazione a un controllo e a tutti i suoi figli.</summary>
public static void Set(Control control, string text)
{
if (string.IsNullOrWhiteSpace(text)) return;
Instance.SetToolTip(control, text);
// Un LabeledCombo è un pannello con dentro etichetta e menu: senza propagare, il
// suggerimento comparirebbe solo sui pochi pixel di bordo fra i due.
foreach (Control child in control.Controls) Set(child, text);
}
}
+13
View File
@@ -32,6 +32,19 @@ public sealed class ExportSettings
public int Width { get; set; }
public int Height { get; set; }
/// <summary>
/// Rapporto larghezza/altezza dell'uscita; 0 conserva quello della sorgente. Cambiarlo
/// non deforma l'immagine: il ritaglio viene preso con il nuovo rapporto dentro il
/// fotogramma, che è la sola cosa sensata da fare quando si passa da 4:3 a 16:9.
/// </summary>
public double AspectRatio { get; set; }
/// <summary>Ritaglio fisso dell'inquadratura, indipendente dal movimento virtuale.</summary>
public bool CropEnabled { get; set; }
public double CropCentreX { get; set; } = 0.5;
public double CropCentreY { get; set; } = 0.5;
public double CropZoom { get; set; } = 1.0;
public double FrameRate { get; set; } = 30.0;
/// <summary>Bitrate medio in megabit al secondo.</summary>