diff --git a/Titano/Diagnostics/AdvancedModuleTests.cs b/Titano/Diagnostics/AdvancedModuleTests.cs
index 308e105..ed1147a 100644
--- a/Titano/Diagnostics/AdvancedModuleTests.cs
+++ b/Titano/Diagnostics/AdvancedModuleTests.cs
@@ -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
+
+ ///
+ /// 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.
+ ///
+ 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();
+ var stamps = new List();
+ 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();
+ 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);
+ }
+ }
+
+ ///
+ /// Copia di metadati con qualche campo cambiato. FrameMetadata è una classe con proprietà
+ /// di sola inizializzazione, quindi non ha la copia con modifica dei record.
+ ///
+ 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
///
diff --git a/Titano/Imaging/GeometryStage.cs b/Titano/Imaging/GeometryStage.cs
index 0dd4e6c..505da2b 100644
--- a/Titano/Imaging/GeometryStage.cs
+++ b/Titano/Imaging/GeometryStage.cs
@@ -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;
diff --git a/Titano/Imaging/RasterWriter.cs b/Titano/Imaging/RasterWriter.cs
new file mode 100644
index 0000000..36bdb64
--- /dev/null
+++ b/Titano/Imaging/RasterWriter.cs
@@ -0,0 +1,381 @@
+using System.Buffers.Binary;
+using System.Drawing.Imaging;
+using System.Globalization;
+using System.Text;
+using Titano.Metadata;
+
+namespace Titano.Imaging;
+
+/// Formato in cui l'importazione scrive i file convertiti.
+public enum RasterFormat
+{
+ /// TIFF a 16 bit per canale, codificato in sRGB: il formato di scambio classico.
+ Tiff16,
+
+ /// DNG lineare a 16 bit: stessi pixel, in luce lineare e con i tag che lo rendono un DNG.
+ LinearDng,
+
+ /// JPEG, tramite il codificatore di sistema.
+ Jpeg,
+}
+
+///
+/// Scrittura di immagini su file, con il contenitore costruito a mano.
+///
+/// Cosa è, e cosa non è, il DNG che questo produce. 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 DNG lineare: 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.
+///
+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
+
+ /// Voce di una directory TIFF, con il valore già ridotto a byte.
+ 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();
+ 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 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 entries,
+ Dictionary placement, uint nextIfd)
+ {
+ Span count = stackalloc byte[2];
+ BinaryPrimitives.WriteUInt16LittleEndian(count, (ushort)entries.Count);
+ stream.Write(count);
+
+ Span 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 next = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(next, nextIfd);
+ stream.Write(next);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 BuildMainEntries(FrameMetadata metadata, int width, int height, bool asDng)
+ {
+ var entries = new List
+ {
+ 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 BuildExifEntries(FrameMetadata metadata, int width, int height)
+ {
+ var entries = new List
+ {
+ 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;
+ }
+
+ /// Frazione che rappresenta un tempo di posa senza perdere i valori tipici (1/125, 1/8000).
+ 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);
+ }
+}
diff --git a/Titano/Pipeline/AutoOptimizer.cs b/Titano/Pipeline/AutoOptimizer.cs
new file mode 100644
index 0000000..78c8708
--- /dev/null
+++ b/Titano/Pipeline/AutoOptimizer.cs
@@ -0,0 +1,314 @@
+using Titano.Analysis;
+using Titano.Core;
+using Titano.Motion;
+
+namespace Titano.Pipeline;
+
+///
+/// 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.
+///
+public static class AutoOptimizer
+{
+ public static List Optimize(TitanoProject project, WorkspaceArea area)
+ {
+ var changes = new List();
+ 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 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 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 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 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(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 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.");
+ }
+ }
+}
diff --git a/Titano/Pipeline/MediaImporter.cs b/Titano/Pipeline/MediaImporter.cs
new file mode 100644
index 0000000..0b6186f
--- /dev/null
+++ b/Titano/Pipeline/MediaImporter.cs
@@ -0,0 +1,497 @@
+using System.Security.Cryptography;
+using Titano.Imaging;
+using Titano.Metadata;
+
+namespace Titano.Pipeline;
+
+/// Cosa fare del file durante l'importazione.
+public enum ImportAction
+{
+ /// Copia il file com'è: nessuna interpretazione, nessuna perdita.
+ Copy,
+
+ /// Converte in TIFF a 16 bit codificato in sRGB.
+ ConvertToTiff,
+
+ /// Converte in DNG lineare a 16 bit.
+ ConvertToDng,
+
+ /// Converte in JPEG.
+ ConvertToJpeg,
+}
+
+/// Come comportarsi quando la destinazione esiste già.
+public enum ImportCollision
+{
+ /// Salta il file: è quello che si vuole reimportando una scheda già scaricata.
+ Skip,
+
+ /// Aggiunge un suffisso numerico.
+ Rename,
+
+ /// Sovrascrive.
+ Overwrite,
+}
+
+/// Impostazioni dell'importazione da scheda o cartella.
+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;
+
+ /// Conserva anche l'originale quando si converte.
+ public bool KeepOriginalWhenConverting { get; set; } = true;
+
+ /// Rilegge il file scritto e ne confronta l'impronta con la sorgente.
+ public bool VerifyCopy { get; set; } = true;
+
+ ///
+ /// 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.
+ ///
+ public bool GroupIntoSessions { get; set; } = true;
+ public double SessionGapMinutes { get; set; } = 45;
+
+ /// Numero minimo di scatti perché un gruppo valga come sessione a sé.
+ public int MinimumSessionFrames { get; set; } = 8;
+
+ public int JpegQuality { get; set; } = 92;
+
+ /// Larghezza massima dei file convertiti; 0 conserva quella originale.
+ 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,
+ };
+}
+
+/// Un supporto da cui si può importare.
+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";
+}
+
+/// Un file candidato con i suoi metadati già letti.
+public sealed record ImportCandidate(string SourcePath, FrameMetadata Metadata, long Size, int SessionIndex);
+
+/// Dove finirà un singolo file, e perché.
+public sealed record ImportStep(ImportCandidate Candidate, string DestinationPath, string? OriginalCopyPath, bool Skipped);
+
+/// Il piano completo, prima di toccare il disco.
+public sealed class ImportPlan
+{
+ public required IReadOnlyList 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);
+
+ /// Cartelle che verranno create, in ordine.
+ public IEnumerable 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);
+}
+
+/// Esito dell'importazione.
+public sealed record ImportResult(int Imported, int Skipped, int Failed, long Bytes, TimeSpan Elapsed,
+ IReadOnlyList Errors, string? FirstFolder);
+
+///
+/// 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ù.
+///
+public static class MediaImporter
+{
+ /// Nomi di cartella che indicano una scheda fotografica.
+ private static readonly string[] CameraFolders = ["DCIM", "PRIVATE", "MISC", "CANONMSC"];
+
+ /// Supporti disponibili, con quelli rimovibili in cima.
+ public static List Volumes()
+ {
+ var volumes = new List();
+
+ 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)];
+ }
+
+ /// Vero se la radice contiene una delle cartelle che le fotocamere creano.
+ 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ public static List Scan(ImportSettings settings, IProgress? progress,
+ CancellationToken cancellation)
+ {
+ if (!Directory.Exists(settings.SourcePath)) return [];
+
+ var option = settings.SearchSubfolders ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
+ List 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ public static List Regroup(IReadOnlyList 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ private static List AssignSessions(
+ List<(string Path, FrameMetadata Metadata, long Size)> ordered, ImportSettings settings)
+ {
+ var candidates = new List(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>();
+ 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>();
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ public static ImportPlan Plan(IReadOnlyList candidates, ImportSettings settings)
+ {
+ var steps = new List(candidates.Count);
+ var taken = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var sessionStarts = new Dictionary();
+ var sessionCounters = new Dictionary();
+ 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 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;
+ }
+
+ /// Esegue il piano.
+ public static ImportResult Execute(ImportPlan plan, ImportSettings settings,
+ IProgress? progress, CancellationToken cancellation)
+ {
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+ var errors = new List();
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+}
diff --git a/Titano/Pipeline/PathTemplate.cs b/Titano/Pipeline/PathTemplate.cs
new file mode 100644
index 0000000..fd117b1
--- /dev/null
+++ b/Titano/Pipeline/PathTemplate.cs
@@ -0,0 +1,186 @@
+using System.Globalization;
+using System.Text;
+using Titano.Metadata;
+
+namespace Titano.Pipeline;
+
+/// Dati che un segnaposto può leggere: lo scatto e la sua posizione nell'importazione.
+public readonly record struct NamingContext(
+ FrameMetadata Metadata,
+ int Index,
+ int SessionIndex,
+ DateTime SessionStart,
+ int SessionCount);
+
+///
+/// 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.
+///
+public static class PathTemplate
+{
+ /// Segnaposto riconosciuti, con la descrizione mostrata nell'interfaccia.
+ 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"),
+ ];
+
+ /// Sostituisce i segnaposto e restituisce un frammento di percorso già ripulito.
+ 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,
+ };
+ }
+
+ ///
+ /// 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.
+ ///
+ 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();
+ }
+
+ /// Compatta separatori ripetuti e spazi ai bordi di ogni livello.
+ 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);
+ }
+
+ ///
+ /// Verifica che un modello sia utilizzabile e spiega il primo problema trovato.
+ /// Restituisce null quando va bene.
+ ///
+ 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;
+ }
+}
diff --git a/Titano/Pipeline/TitanoProject.cs b/Titano/Pipeline/TitanoProject.cs
index 5fbed58..faed85d 100644
--- a/Titano/Pipeline/TitanoProject.cs
+++ b/Titano/Pipeline/TitanoProject.cs
@@ -123,7 +123,8 @@ public sealed class TitanoProject
public int EffectiveOrientation => General.OrientationOverride ?? Orientation.Orientation;
/// Vero se serve lo stadio geometrico finale, quindi un ricampionamento in più.
- public bool NeedsGeometry => Camera.Enabled || Stabilization.Enabled;
+ public bool NeedsGeometry => Camera.Enabled || Stabilization.Enabled || Export.CropEnabled ||
+ Export.AspectRatio > 0.01;
///
/// 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
}
}
- /// Inquadratura effettiva a un dato punto della sequenza, vincoli compresi.
+ ///
+ /// 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.
+ ///
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);
+ }
+
+ /// Rapporto larghezza/altezza dell'uscita: quello scelto, o quello della sorgente.
+ 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;
+ }
}
}
diff --git a/Titano/Program.cs b/Titano/Program.cs
index bc74ead..9154c8a 100644
--- a/Titano/Program.cs
+++ b/Titano/Program.cs
@@ -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);
diff --git a/Titano/README.md b/Titano/README.md
index 2b42134..a54d934 100644
--- a/Titano/README.md
+++ b/Titano/README.md
@@ -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.
diff --git a/Titano/UI/FramingEditor.cs b/Titano/UI/FramingEditor.cs
new file mode 100644
index 0000000..e3af4d4
--- /dev/null
+++ b/Titano/UI/FramingEditor.cs
@@ -0,0 +1,215 @@
+using Titano.Video;
+
+namespace Titano.UI;
+
+///
+/// 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.
+///
+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;
+
+ /// Rapporto larghezza/altezza del fotogramma sorgente.
+ public double SourceAspect { get; set; } = 4.0 / 3.0;
+
+ /// Rapporto richiesto per l'uscita.
+ 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;
+ }
+
+ /// Riquadro che rappresenta il fotogramma sorgente, con le sue proporzioni.
+ 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);
+ }
+ }
+
+ /// Rettangolo di ritaglio, con il rapporto dell'uscita e vincolato dentro la sorgente.
+ 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);
+ }
+}
diff --git a/Titano/UI/ImportPanel.cs b/Titano/UI/ImportPanel.cs
new file mode 100644
index 0000000..f2c5755
--- /dev/null
+++ b/Titano/UI/ImportPanel.cs
@@ -0,0 +1,342 @@
+using Titano.Pipeline;
+
+namespace Titano.UI;
+
+///
+/// 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.
+///
+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 _candidates = [];
+ private ImportPlan? _current;
+
+ /// L'utente ha scelto un supporto: il chiamante aggiorna la sorgente e rilegge.
+ public event EventHandler? 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;
+
+ /// Rilegge l'elenco dei supporti collegati.
+ public void Refresh()
+ {
+ _volumes.SetVolumes(MediaImporter.Volumes(), _settings.SourcePath);
+ _plan.Update(_settings, _candidates, _current);
+ }
+
+ public void SetScan(List 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 _items = [];
+ private string _selected = string.Empty;
+ private int _hovered = -1;
+
+ public event EventHandler? VolumeChosen;
+
+ public VolumeList()
+ {
+ SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
+ ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
+ BackColor = Theme.Background;
+ Cursor = Cursors.Hand;
+ }
+
+ public void SetVolumes(List 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 _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 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);
+ }
+ }
+ }
+}
diff --git a/Titano/UI/MainForm.cs b/Titano/UI/MainForm.cs
index a3613fb..a5dc158 100644
--- a/Titano/UI/MainForm.cs
+++ b/Titano/UI/MainForm.cs
@@ -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 _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;
}
+ /// I pulsanti della barra, nell'ordine in cui compaiono quando sono visibili.
+ private IEnumerable ToolbarButtons =>
+ [
+ _importButton, _addFilesButton, _addFolderButton, _clearButton, _analyzeButton,
+ _optimizeButton, _exportButton,
+ ];
+
+ ///
+ /// 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 Visible: 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.
+ ///
+ private static void LayoutToolbar(IReadOnlyList 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.");
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ private void UpdateToolbarFor(WorkspaceSection section)
+ {
+ bool onSequence = section == WorkspaceSection.Sequence;
+ bool onArchive = section == WorkspaceSection.Archive;
+
+ var shown = new List();
+ 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);
}
/// Allinea tutte le viste sullo stesso fotogramma, qualunque l'abbia scelto.
@@ -405,6 +481,108 @@ internal sealed class MainForm : Form
ShowPreview(index);
}
+ ///
+ /// 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.
+ ///
+ 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(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();
+ }
+ }
+
+ /// Rileva le impostazioni ottimali per la sezione in vista e dice cosa ha cambiato.
+ 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 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();
diff --git a/Titano/UI/NavigationRail.cs b/Titano/UI/NavigationRail.cs
index 0dba6f9..88bdd68 100644
--- a/Titano/UI/NavigationRail.cs
+++ b/Titano/UI/NavigationRail.cs
@@ -5,6 +5,7 @@ namespace Titano.UI;
/// Le sezioni in cui è divisa la finestra, nell'ordine in cui compaiono nella barra.
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);
diff --git a/Titano/UI/SettingsPanel.cs b/Titano/UI/SettingsPanel.cs
index a8d6af0..3cabe11 100644
--- a/Titano/UI/SettingsPanel.cs
+++ b/Titano/UI/SettingsPanel.cs
@@ -6,27 +6,35 @@ using Titano.Video;
namespace Titano.UI;
///
-/// Colonna delle impostazioni. Non ha più schede proprie: mostra la pagina della sezione
-/// scelta nella barra di navigazione, così la barra governa insieme il contenuto principale
-/// e i comandi che lo riguardano, e non esistono due gerarchie di schede da tenere allineate.
+/// Colonna delle impostazioni. Mostra la pagina della sezione scelta nella barra di
+/// navigazione, così la barra governa insieme il contenuto principale e i comandi che lo
+/// riguardano, e non esistono due gerarchie di schede da tenere allineate.
///
-/// Ogni controllo dichiara quale parte della pipeline invalida. C'è differenza fra spostare
-/// un cursore che ricalcola una curva già misurata e sceglierne uno che obbliga a rileggere
-/// mille file, e il programma deve saperla.
+/// Le spiegazioni non stanno più 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 non ha quel vincolo, e può
+/// dire l'unica cosa che conta davvero — perché quel parametro esiste, e cosa succede a
+/// spostarlo nella direzione sbagliata.
///
internal sealed class SettingsPanel : Panel
{
/// Valori Exif corrispondenti alle voci del menu dell'orientamento; null = automatico.
private static readonly int?[] OrientationValues = [null, 1, 2, 4, 6, 3, 8];
+ /// Rapporti d'uscita offerti; 0 conserva quello della sorgente.
+ private static readonly double[] AspectValues = [0, 16.0 / 9.0, 21.0 / 9.0, 4.0 / 3.0, 1.0, 9.0 / 16.0];
+
private readonly TitanoProject _project;
private readonly AppSettings _app;
+ private readonly ImportSettings _import;
private readonly Dictionary _pages = [];
private readonly Dictionary _autoSliders = [];
private LabeledCombo? _orientationCombo;
private Label? _analysisNote;
+ private TextBox? _importSourceBox;
private CameraEditor? _cameraEditor;
+ private FramingEditor? _framingEditor;
private SplineEditor? _rampEditor;
private ParameterSlider? _keyframeCentreX;
private ParameterSlider? _keyframeCentreY;
@@ -40,13 +48,15 @@ internal sealed class SettingsPanel : Panel
public event EventHandler? PreviewInvalidated;
public event EventHandler? BrowseOutputRequested;
public event EventHandler? AppSettingsChanged;
+ public event EventHandler? ImportChanged;
public TextBox OutputPathBox { get; }
- public SettingsPanel(TitanoProject project, AppSettings app)
+ public SettingsPanel(TitanoProject project, AppSettings app, ImportSettings import)
{
_project = project;
_app = app;
+ _import = import;
BackColor = Theme.Surface;
OutputPathBox = new TextBox
@@ -63,6 +73,7 @@ internal sealed class SettingsPanel : Panel
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
};
+ _pages[WorkspaceSection.Archive] = BuildArchivePage();
_pages[WorkspaceSection.Sequence] = BuildSequencePage();
_pages[WorkspaceSection.Exposure] = BuildExposurePage();
_pages[WorkspaceSection.Motion] = BuildMotionPage();
@@ -96,10 +107,12 @@ internal sealed class SettingsPanel : Panel
_orientationCombo.Combo.SelectedIndex = selected;
}
- ///
- /// Riporta nei cursori i valori che il direttore ha dedotto, con il motivo di ciascuno.
- /// Quelli di cui l'utente ha preso il controllo restano dove sono.
- ///
+ /// Riporta nella casella la sorgente scelta dall'elenco dei supporti.
+ public void ShowImportSource()
+ {
+ if (_importSourceBox is not null) _importSourceBox.Text = _import.SourcePath;
+ }
+
public void ShowAutoDecisions(IReadOnlyDictionary decisions)
{
foreach (var (key, slider) in _autoSliders)
@@ -151,10 +164,240 @@ internal sealed class SettingsPanel : Panel
public void ShowSequenceGeometry()
{
- if (_cameraEditor is null) return;
var (width, height) = _project.ResolveNativeSize();
- if (width > 0 && height > 0) _cameraEditor.Aspect = width / (double)height;
- _cameraEditor.Invalidate();
+ if (width <= 0 || height <= 0) return;
+
+ if (_cameraEditor is not null) _cameraEditor.Aspect = width / (double)height;
+ if (_framingEditor is not null)
+ {
+ _framingEditor.SourceAspect = width / (double)height;
+ _framingEditor.OutputAspect = _project.OutputAspect;
+ _framingEditor.Invalidate();
+ }
+ }
+
+ // ------------------------------------------------------------------ Archivio
+
+ private Panel BuildArchivePage()
+ {
+ var stack = NewStack();
+
+ stack.Add(new SectionHeader("Origine"));
+
+ _importSourceBox = new TextBox
+ {
+ BackColor = Theme.SurfaceAlt,
+ ForeColor = Theme.Text,
+ BorderStyle = BorderStyle.FixedSingle,
+ Font = Theme.Body,
+ Dock = DockStyle.Top,
+ Text = _import.SourcePath,
+ };
+ _importSourceBox.TextChanged += (_, _) =>
+ {
+ _import.SourcePath = _importSourceBox.Text;
+ ImportChanged?.Invoke(this, EventArgs.Empty);
+ };
+ Tips.Set(_importSourceBox,
+ "Cartella da cui leggere. Scegliendo un supporto dall'elenco a sinistra questa " +
+ "casella si compila da sola; si può anche incollare un percorso qualunque, per " +
+ "esempio una cartella già scaricata sul disco.");
+ stack.Add(_importSourceBox);
+
+ var browseSource = new DarkButton { Text = "Scegli cartella di origine…", Height = 30, Dock = DockStyle.Top };
+ browseSource.Click += (_, _) =>
+ {
+ using var dialog = new FolderBrowserDialog { Description = "Cartella da cui importare" };
+ if (dialog.ShowDialog(this) != DialogResult.OK) return;
+ _import.SourcePath = dialog.SelectedPath;
+ ShowImportSource();
+ ImportChanged?.Invoke(this, EventArgs.Empty);
+ };
+ stack.Add(browseSource);
+
+ stack.Add(Check("Cerca nelle sottocartelle", _import.SearchSubfolders,
+ "Le fotocamere non mettono tutto in un posto solo: dentro DCIM creano una cartella " +
+ "ogni pochi centinaia di scatti, e alcune separano per giorno. Senza la ricerca " +
+ "ricorsiva si importerebbe solo quello che sta nel primo livello.",
+ value => { _import.SearchSubfolders = value; ImportChanged?.Invoke(this, EventArgs.Empty); }));
+
+ stack.Add(new SectionHeader("Destinazione"));
+
+ var destinationBox = new TextBox
+ {
+ BackColor = Theme.SurfaceAlt,
+ ForeColor = Theme.Text,
+ BorderStyle = BorderStyle.FixedSingle,
+ Font = Theme.Body,
+ Dock = DockStyle.Top,
+ Text = _import.DestinationRoot,
+ };
+ destinationBox.TextChanged += (_, _) =>
+ {
+ _import.DestinationRoot = destinationBox.Text;
+ ImportChanged?.Invoke(this, EventArgs.Empty);
+ };
+ Tips.Set(destinationBox,
+ "Radice dell'archivio. Sotto questa cartella il modello qui sotto costruisce i " +
+ "livelli: la radice resta fissa negli anni, il modello decide come si ramifica.");
+ stack.Add(destinationBox);
+
+ var browseDestination = new DarkButton
+ {
+ Text = "Scegli cartella di destinazione…",
+ Height = 30,
+ Dock = DockStyle.Top,
+ };
+ browseDestination.Click += (_, _) =>
+ {
+ using var dialog = new FolderBrowserDialog { Description = "Radice dell'archivio" };
+ if (dialog.ShowDialog(this) != DialogResult.OK) return;
+ _import.DestinationRoot = dialog.SelectedPath;
+ destinationBox.Text = dialog.SelectedPath;
+ ImportChanged?.Invoke(this, EventArgs.Empty);
+ };
+ stack.Add(browseDestination);
+
+ stack.Add(new SectionHeader("Nomi di cartella e file"));
+
+ string tokenHelp = "Segnaposto disponibili:\n" +
+ string.Join("\n", PathTemplate.Tokens.Select(t => "{" + t.Token + "} " + t.Description)) +
+ "\n\nSi può indicare un formato dopo i due punti, per esempio {n:0000} o " +
+ "{data:yyyyMMdd}. I caratteri che il file system non accetta vengono sostituiti.";
+
+ var folderBox = TemplateBox(_import.FolderTemplate, value =>
+ {
+ _import.FolderTemplate = value;
+ ImportChanged?.Invoke(this, EventArgs.Empty);
+ });
+ Tips.Set(folderBox,
+ "Modello delle cartelle sotto la radice. La barra separa i livelli: " +
+ "{anno}/{data} {fotocamera} produce 2026/2026-08-15 GoPro HERO8.\n\n" + tokenHelp);
+ stack.Add(Labelled("Modello delle cartelle", folderBox));
+
+ var fileBox = TemplateBox(_import.FileTemplate, value =>
+ {
+ _import.FileTemplate = value;
+ ImportChanged?.Invoke(this, EventArgs.Empty);
+ });
+ Tips.Set(fileBox,
+ "Modello del nome file, senza estensione: quella la decide il formato di uscita. " +
+ "Un nome che comincia con la data resta ordinabile alfabeticamente, che è la " +
+ "ragione per cui conviene metterla davanti.\n\n" + tokenHelp);
+ stack.Add(Labelled("Modello del nome file", fileBox));
+
+ stack.Add(Check("Dividi in sessioni sulle pause", _import.GroupIntoSessions,
+ "Una scheda contiene di norma più riprese. Separarle qui, sulla pausa fra due " +
+ "scatti, evita di doverle distinguere dopo guardando gli orari — che è il lavoro " +
+ "noioso che si finisce sempre per rimandare.",
+ value => { _import.GroupIntoSessions = value; ImportChanged?.Invoke(this, EventArgs.Empty); }));
+
+ stack.Add(Slider("Pausa che apre una sessione", 5, 240, _import.SessionGapMinutes, 5, "0", "min",
+ "Quanto silenzio fra due scatti perché comincino due riprese diverse. Su un " +
+ "time-lapse con intervallo di un minuto bastano dieci minuti; con intervalli " +
+ "lunghi va alzata, o ogni scatto diventerebbe una sessione a sé.",
+ value => { _import.SessionGapMinutes = value; ImportChanged?.Invoke(this, EventArgs.Empty); }));
+
+ stack.Add(Slider("Scatti minimi per una sessione", 1, 60, _import.MinimumSessionFrames, 1, "0", string.Empty,
+ "Un gruppo più piccolo di così confluisce nel precedente invece di generare una " +
+ "cartella propria: quasi sempre si tratta degli scatti di prova fatti prima di " +
+ "cominciare davvero.",
+ value => { _import.MinimumSessionFrames = (int)value; ImportChanged?.Invoke(this, EventArgs.Empty); }));
+
+ stack.Add(new SectionHeader("Conversione"));
+
+ stack.Add(Combo("Cosa scrivere",
+ ["Copia il file com'è", "Converti in TIFF 16 bit", "Converti in DNG lineare", "Converti in JPEG"],
+ (int)_import.Action,
+ "La copia è la scelta giusta quasi sempre: conserva il grezzo, quindi la libertà " +
+ "di rifare lo sviluppo.\n\n" +
+ "Attenzione al DNG: quello prodotto qui è un DNG LINEARE, cioè con i pixel già " +
+ "interpolati. È un file valido e apribile ovunque, ma non restituisce la libertà " +
+ "del grezzo, perché per leggere i dati del sensore di un formato proprietario " +
+ "servirebbe la libreria del produttore — che questo programma, per scelta, non usa. " +
+ "Se il grezzo serve, si copia e basta.\n\n" +
+ "TIFF e JPEG servono a consegnare qualcosa a un altro programma, non ad archiviare.",
+ index =>
+ {
+ _import.Action = (ImportAction)Math.Clamp(index, 0, 3);
+ ImportChanged?.Invoke(this, EventArgs.Empty);
+ }));
+
+ stack.Add(Check("Conserva anche l'originale quando converti", _import.KeepOriginalWhenConverting,
+ "Mette una copia intatta in una sottocartella «originali». Convertire senza " +
+ "conservare significa buttare via informazione che non si recupera più.",
+ value => { _import.KeepOriginalWhenConverting = value; ImportChanged?.Invoke(this, EventArgs.Empty); }));
+
+ stack.Add(Slider("Qualità JPEG", 60, 100, _import.JpegQuality, 1, "0", string.Empty,
+ "Vale solo per la conversione in JPEG. Sotto 85 gli artefatti si vedono nei cieli " +
+ "sfumati, che è esattamente dove un time-lapse li mostra di più.",
+ value => { _import.JpegQuality = (int)value; ImportChanged?.Invoke(this, EventArgs.Empty); }));
+
+ stack.Add(Slider("Larghezza dei convertiti", 0, 8192, _import.ConvertedWidth, 256, "0", "px",
+ "Zero conserva la risoluzione originale. Un valore più basso serve a produrre " +
+ "copie di lavoro leggere, non ad archiviare.",
+ value => { _import.ConvertedWidth = (int)value; ImportChanged?.Invoke(this, EventArgs.Empty); }));
+
+ stack.Add(new SectionHeader("Sicurezza"));
+
+ stack.Add(Combo("Se il file esiste già",
+ ["Salta", "Rinomina con un suffisso", "Sovrascrivi"],
+ (int)_import.Collision,
+ "Saltare è la scelta giusta quando si reimporta una scheda già scaricata: " +
+ "riprende solo quello che manca. Sovrascrivere è l'unica opzione che può " +
+ "distruggere qualcosa, e per questo non è la predefinita.",
+ index =>
+ {
+ _import.Collision = (ImportCollision)Math.Clamp(index, 0, 2);
+ ImportChanged?.Invoke(this, EventArgs.Empty);
+ }));
+
+ stack.Add(Check("Verifica dopo la copia", _import.VerifyCopy,
+ "Rilegge il file scritto e ne confronta l'impronta con l'originale. Non è zelo: " +
+ "una scheda che si scollega a metà copia, 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 la scheda è stata formattata. Costa una " +
+ "rilettura per file.",
+ value => { _import.VerifyCopy = value; ImportChanged?.Invoke(this, EventArgs.Empty); }));
+
+ return stack.Panel;
+ }
+
+ private static TextBox TemplateBox(string value, Action onChange)
+ {
+ var box = new TextBox
+ {
+ BackColor = Theme.SurfaceAlt,
+ ForeColor = Theme.Text,
+ BorderStyle = BorderStyle.FixedSingle,
+ Font = Theme.Body,
+ Dock = DockStyle.Top,
+ Text = value,
+ };
+ box.TextChanged += (_, _) => onChange(box.Text);
+ return box;
+ }
+
+ /// Etichetta sopra un controllo che non ne ha una propria.
+ private static Panel Labelled(string caption, Control content)
+ {
+ var host = new Panel { Height = 46, BackColor = Theme.Surface };
+ var label = new Label
+ {
+ Text = caption,
+ Font = Theme.Small,
+ ForeColor = Theme.TextMuted,
+ Dock = DockStyle.Top,
+ Height = 16,
+ TextAlign = ContentAlignment.MiddleLeft,
+ };
+
+ content.Dock = DockStyle.Top;
+ host.Controls.Add(content);
+ host.Controls.Add(label);
+
+ // Il suggerimento del contenuto vale anche per l'etichetta: è lì che il puntatore va.
+ return host;
}
// ------------------------------------------------------------------ Sequenza
@@ -167,6 +410,10 @@ internal sealed class SettingsPanel : Panel
stack.Add(Combo("Risoluzione di lavoro",
["Nativa (piena risoluzione)", "3840 px (4K UHD)", "2560 px", "1920 px (Full HD)", "1280 px"],
WorkingWidthToIndex(_project.General.WorkingWidth),
+ "A quanti pixel leggere i fotogrammi. Con il movimento virtuale o la " +
+ "stabilizzazione attivi conviene la nativa, perché il ritaglio deve avere pixel da " +
+ "cui attingere; senza, scendere accorcia molto i tempi e non cambia il risultato " +
+ "se l'uscita è comunque più piccola.",
index =>
{
_project.General.WorkingWidth = index switch { 1 => 3840, 2 => 2560, 3 => 1920, 4 => 1280, _ => 0 };
@@ -177,6 +424,12 @@ internal sealed class SettingsPanel : Panel
["Automatico", "Nessuna rotazione", "Specchia orizzontalmente", "Specchia verticalmente",
"Ruota 90° in senso orario", "Ruota 180°", "Ruota 90° in senso antiorario"],
Array.IndexOf(OrientationValues, _project.General.OrientationOverride),
+ "Il tag Exif dice come raddrizzare l'immagine, ma non dice se il decodificatore " +
+ "di sistema l'ha già fatto: alcuni codec applicano la trasformazione da soli, " +
+ "altri no. Applicarla due volte ribalta il fotogramma, non applicarla mai lo " +
+ "lascia coricato. In automatico Titano confronta le dimensioni restituite con " +
+ "quelle dichiarate e decide di conseguenza; se il risultato non convince, " +
+ "l'anteprima mostra subito l'effetto di una scelta manuale.",
index =>
{
_project.General.OrientationOverride = OrientationValues[Math.Clamp(index, 0, OrientationValues.Length - 1)];
@@ -185,6 +438,9 @@ internal sealed class SettingsPanel : Panel
stack.Add(_orientationCombo);
stack.Add(Slider("Tolleranza sulla cadenza", 0.05, 1.0, _project.General.CadenceTolerance, 0.05, "0.00", "×",
+ "Di quanto un intervallo può discostarsi dalla cadenza nominale prima di essere " +
+ "segnalato come anomalo. Serve a distinguere una pausa vera dell'intervallometro " +
+ "dal normale tremolio di qualche decimo di secondo.",
value =>
{
_project.General.CadenceTolerance = value;
@@ -195,19 +451,24 @@ internal sealed class SettingsPanel : Panel
stack.Add(Combo("Profilo",
["Bozza — veloce", "Standard — equilibrio", "Massima — resa migliore"],
(int)_project.General.Quality,
+ "Governa finezza del campo vettoriale, campioni della sfocatura, riquadri della " +
+ "correlazione di fase e risoluzione delle passate di analisi, e sovrascrive i " +
+ "cursori delle sezioni avanzate. Massima costa circa tre volte Standard e si vede " +
+ "nei bordi dei soggetti in movimento; su una sequenza ferma non cambia nulla.",
index =>
{
_project.ApplyQualityProfile((QualityProfile)Math.Clamp(index, 0, 2));
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
- stack.Add(Note("Il profilo governa finezza del campo vettoriale, campioni della sfocatura, " +
- "riquadri della correlazione di fase e risoluzione delle passate di analisi."));
-
stack.Add(new SectionHeader("Prestazioni e memoria"));
stack.Add(AutoSlider(AutoKey.AnalysisWidth, "Larghezza della passata di analisi", 256, 2048,
_project.General.AnalysisWidth, 64, "0", "px",
+ "A quanti pixel misurare luminanza e spostamenti. La media logaritmica è " +
+ "invariante alla scala, quindi oltre un certo punto si paga tempo per nulla; " +
+ "scendere troppo però toglie tessitura alla correlazione di fase, che ha bisogno " +
+ "di dettaglio per agganciare qualcosa.",
value =>
{
_project.General.AnalysisWidth = (int)value;
@@ -215,6 +476,9 @@ internal sealed class SettingsPanel : Panel
}));
stack.Add(Slider("Decodifiche simultanee", 1, 16, _project.General.DecodeParallelism, 1, "0", "thread",
+ "Quanti fotogrammi decodificare insieme. Oltre la metà dei processori i thread " +
+ "cominciano a contendersi il disco invece di sommarsi, perché la decodifica di un " +
+ "RAW è dominata dalla lettura del file.",
value =>
{
_project.General.DecodeParallelism = (int)value;
@@ -222,19 +486,25 @@ internal sealed class SettingsPanel : Panel
}));
stack.Add(Slider("Lettura in anticipo", 0, 32, _project.Cache.PrefetchDepth, 1, "0", "fotogrammi",
+ "Quanti fotogrammi leggere prima che servano. Serve a tenere occupati tutti i " +
+ "processori sulla decodifica, che è la parte più lenta della pipeline e l'unica " +
+ "che non si può accelerare in altro modo.",
value => _project.Cache.PrefetchDepth = (int)value));
stack.Add(AutoSlider(AutoKey.MemoryBudget, "Tetto di memoria", 256, 32768,
_project.Cache.MemoryBudgetMiB, 256, "0", "MiB",
+ "Memoria concessa ai fotogrammi letti in anticipo. La finestra attiva sta sempre " +
+ "in memoria comunque, perché chi la usa ha bisogno di tutti i suoi fotogrammi " +
+ "insieme: il tetto governa solo l'anticipo. Oltre il tetto i fotogrammi già letti " +
+ "aspettano su disco e vengono ripresi una volta sola.",
value => _project.Cache.MemoryBudgetMiB = (int)value));
stack.Add(Check("Parcheggia su disco i fotogrammi in eccesso", _project.Cache.AllowDiskSpill,
+ "Quando il tetto è raggiunto, i fotogrammi letti in anticipo aspettano in un file " +
+ "temporaneo invece di essere scartati. Il file si cancella da sé alla chiusura, " +
+ "anche se il programma termina male.",
value => _project.Cache.AllowDiskSpill = value));
- stack.Add(Note("La finestra attiva sta sempre in memoria; il tetto governa la lettura in " +
- "anticipo. Oltre il tetto i fotogrammi già letti aspettano su disco e vengono " +
- "ripresi una volta sola. Il file di parcheggio si cancella da sé."));
-
stack.Add(new SectionHeader("Esito dell'analisi"));
_analysisNote = Note("Esegui l'analisi per vedere cosa il motore ha dedotto dalla sequenza.");
stack.Add(_analysisNote);
@@ -250,36 +520,61 @@ internal sealed class SettingsPanel : Panel
stack.Add(new SectionHeader("Deflicker"));
- stack.Add(Check("Correzione dell'esposizione attiva", _project.Deflicker.Enabled, value =>
- {
- _project.Deflicker.Enabled = value;
- DeflickerChanged?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Correzione dell'esposizione attiva", _project.Deflicker.Enabled,
+ "Toglie lo sfarfallio dovuto alle micro-variazioni del diaframma fra uno scatto e " +
+ "l'altro, senza toccare le variazioni di luce vere.",
+ value =>
+ {
+ _project.Deflicker.Enabled = value;
+ DeflickerChanged?.Invoke(this, EventArgs.Empty);
+ }));
stack.Add(AutoSlider(AutoKey.DeflickerWindow, "Finestra temporale", 3, 121,
_project.Deflicker.WindowFrames, 2, "0", "fotogrammi",
+ "Su quanti fotogrammi calcolare la curva obiettivo. Troppo corta lascia passare " +
+ "lo sfarfallio; troppo lunga comincia a mangiare le variazioni di luce vere, come " +
+ "il calare del tramonto. In automatico viene scelta come quattro periodi dello " +
+ "sfarfallio misurato.",
value => { _project.Deflicker.WindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità della correzione", 0, 1, _project.Deflicker.Strength, 0.05, "0.00", string.Empty,
+ "Quanta parte della correzione calcolata applicare. A 1 il fotogramma raggiunge " +
+ "la curva obiettivo; sotto, ne percorre solo una frazione, che serve quando si " +
+ "vuole attenuare lo sfarfallio senza appiattire del tutto la scena.",
value => { _project.Deflicker.Strength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Correzione massima", 0.1, 3.0, _project.Deflicker.MaxCorrectionStops, 0.1, "0.0", "EV",
+ "Limite di sicurezza. Impedisce che un fotogramma molto anomalo trascini la " +
+ "correzione oltre quanto sia ragionevole: meglio uno scatto che resta diverso di " +
+ "una correzione che ne rovina dieci attorno.",
value => { _project.Deflicker.MaxCorrectionStops = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
- stack.Add(Check("Scarta i fotogrammi anomali", _project.Deflicker.RejectOutliers, value =>
- {
- _project.Deflicker.RejectOutliers = value;
- DeflickerChanged?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Scarta i fotogrammi anomali", _project.Deflicker.RejectOutliers,
+ "Una seconda passata neutralizza gli scatti che si discostano troppo — un lampo, " +
+ "un passante, un'auto che illumina la scena — così non spostano la curva degli " +
+ "altri. Funziona quando le anomalie sono sparse; su un tratto contiguo che si " +
+ "discosta, la regressione se ne accorge e non si irrigidisce.",
+ value =>
+ {
+ _project.Deflicker.RejectOutliers = value;
+ DeflickerChanged?.Invoke(this, EventArgs.Empty);
+ }));
- stack.Add(Check("Proteggi le alte luci", _project.Deflicker.ProtectHighlights, value =>
- {
- _project.Deflicker.ProtectHighlights = value;
- PreviewInvalidated?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Proteggi le alte luci", _project.Deflicker.ProtectHighlights,
+ "Quando la correzione schiarisce, la parte di segnale che supererebbe il bianco " +
+ "viene ripiegata con una curva morbida invece che troncata. Senza, si formano le " +
+ "macchie piatte e le derive di tinta tipiche dei bordi bruciati.",
+ value =>
+ {
+ _project.Deflicker.ProtectHighlights = value;
+ PreviewInvalidated?.Invoke(this, EventArgs.Empty);
+ }));
stack.Add(Slider("Innesco della compressione", 0.4, 0.98, _project.Deflicker.HighlightKnee, 0.02, "0.00",
string.Empty,
+ "Da quale livello comincia la compressione morbida delle alte luci. Più basso " +
+ "protegge di più e appiattisce prima; più alto conserva il contrasto fino quasi " +
+ "al bianco.",
value => { _project.Deflicker.HighlightKnee = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(new SectionHeader("Deflicker per regioni"));
@@ -287,6 +582,13 @@ internal sealed class SettingsPanel : Panel
stack.Add(Combo("Divisione del fotogramma",
["Nessuna — una sola curva", "Cielo e paesaggio (linea d'orizzonte)", "Per luminanza"],
_project.Regions.Mode switch { RegionMode.SkyGround => 1, RegionMode.Luminance => 2, _ => 0 },
+ "Una nuvola densa che attraversa il cielo abbassa la luminanza media del " +
+ "fotogramma; il deflicker legge un calo di luce e schiarisce tutto, paesaggio " +
+ "compreso, che invece non era cambiato. Con due regioni ciascuna riceve la propria " +
+ "curva e il paesaggio resta fermo.\n\n" +
+ "La divisione per orizzonte cerca il gradino più marcato del profilo di luminanza " +
+ "per riga; quella per luminanza non fa ipotesi sulla forma, e serve quando in cima " +
+ "all'inquadratura c'è un primo piano invece del cielo.",
index =>
{
_project.Regions.Mode = index switch
@@ -299,56 +601,84 @@ internal sealed class SettingsPanel : Panel
}));
stack.Add(Slider("Indipendenza delle regioni", 0, 1, _project.Regions.Independence, 0.05, "0.00", string.Empty,
+ "Quanto le due curve possono allontanarsi da quella comune. A 0 si comportano come " +
+ "un fotogramma solo; a 1 ciascuna regione insegue esclusivamente la propria " +
+ "luminanza, che su scene molto separate può far sembrare le due parti scollegate.",
value => { _project.Regions.Independence = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Sfumatura del confine", 0.01, 0.20, _project.Regions.Feather, 0.01, "0.00", string.Empty,
+ "Ampiezza della transizione fra le due regioni, in frazione dell'altezza. I valori " +
+ "intermedi della maschera non sono un difetto: sono ciò che evita di vedere il " +
+ "confine fra le due correzioni come una linea netta in mezzo all'immagine.",
value => { _project.Regions.Feather = value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Fotogrammi campionati", 3, 48, _project.Regions.SampleFrames, 1, "0", string.Empty,
+ "Quanti scatti usare per costruire la maschera. Se ne calcola la mediana temporale, " +
+ "che toglie di mezzo nuvole e passanti e lascia la struttura fissa della scena: " +
+ "è il motivo per cui la maschera non sfarfalla da un fotogramma all'altro.",
value => { _project.Regions.SampleFrames = (int)value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
- stack.Add(Note("La maschera nasce dalla mediana temporale di un campione di fotogrammi, che " +
- "toglie di mezzo nuvole e passanti. Impedisce che il transito di una nuvola sul " +
- "cielo faccia schiarire anche il paesaggio, che invece non è cambiato."));
-
stack.Add(new SectionHeader("Transizioni giorno-notte"));
- stack.Add(Check("Ammorbidisci i cambi di impostazione", _project.HolyGrail.Enabled, value =>
- {
- _project.HolyGrail.Enabled = value;
- DeflickerChanged?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Ammorbidisci i cambi di impostazione", _project.HolyGrail.Enabled,
+ "Attraversando il tramonto la macchina deve cambiare tempo, ISO o diaframma, e " +
+ "ogni cambio è quantizzato: nel filmato si vede uno scalino netto. Il deflicker " +
+ "ordinario non aiuta, perché un gradino non è rumore da mediare e la finestra " +
+ "mobile lo trasforma in una rampa con due spigoli. Qui l'ampiezza si legge esatta " +
+ "nei metadati e viene ridistribuita su una transizione a derivata nulla agli estremi.",
+ value =>
+ {
+ _project.HolyGrail.Enabled = value;
+ DeflickerChanged?.Invoke(this, EventArgs.Empty);
+ }));
stack.Add(AutoSlider(AutoKey.TransitionFrames, "Lunghezza della transizione", 4, 240,
_project.HolyGrail.TransitionFrames, 2, "0", "fotogrammi",
+ "Su quanti fotogrammi spalmare ogni gradino. Inutile spalmarne uno su cinquanta se " +
+ "il successivo arriva al ventesimo: le due transizioni si sovrapporrebbero e " +
+ "nessuna delle due sarebbe morbida. In automatico si prende metà della distanza " +
+ "tipica fra i cambi.",
value => { _project.HolyGrail.TransitionFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Soglia di riconoscimento", 0.05, 1.0, _project.HolyGrail.StepThresholdStops, 0.05, "0.00", "EV",
+ "Quanto deve cambiare il valore di esposizione perché si parli di cambio di " +
+ "impostazione e non di normale oscillazione del diaframma.",
value => { _project.HolyGrail.StepThresholdStops = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
- stack.Add(Check("Ricava i salti dai metadati", _project.HolyGrail.UseMetadata, value =>
- {
- _project.HolyGrail.UseMetadata = value;
- DeflickerChanged?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Ricava i salti dai metadati", _project.HolyGrail.UseMetadata,
+ "Il valore di esposizione vale log2(N²/t) − log2(ISO/100), quindi la sua variazione " +
+ "fra due scatti è esattamente ciò che la macchina ha deciso di cambiare: nota senza " +
+ "incertezza. Spegnendolo, i salti vengono dedotti dalla sola luminanza misurata, " +
+ "che è meno preciso ma funziona anche senza metadati.\n\n" +
+ "Un salto dichiarato che la luminanza non ha recepito — fotogramma già saturo, " +
+ "oppure scena che ha compensato — viene lasciato stare: correggerlo introdurrebbe " +
+ "il gradino invece di toglierlo.",
+ value =>
+ {
+ _project.HolyGrail.UseMetadata = value;
+ DeflickerChanged?.Invoke(this, EventArgs.Empty);
+ }));
- stack.Add(Check("Liscia il bilanciamento del bianco", _project.HolyGrail.SmoothColor, value =>
- {
- _project.HolyGrail.SmoothColor = value;
- DeflickerChanged?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Liscia il bilanciamento del bianco", _project.HolyGrail.SmoothColor,
+ "Liscia il rapporto fra i canali invece della loro luminanza: toglie il tremolio " +
+ "del bilanciamento automatico e lascia intatto il viaggio verso il caldo del " +
+ "tramonto, che è il soggetto e non un difetto.",
+ value =>
+ {
+ _project.HolyGrail.SmoothColor = value;
+ DeflickerChanged?.Invoke(this, EventArgs.Empty);
+ }));
stack.Add(Slider("Finestra della lisciatura cromatica", 5, 201, _project.HolyGrail.ColorWindowFrames, 2, "0",
"fotogrammi",
+ "Va tenuta più lunga della finestra del deflicker: la deriva cromatica è un " +
+ "fenomeno lento, e una finestra corta la seguirebbe invece di toglierla.",
value => { _project.HolyGrail.ColorWindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità cromatica", 0, 1, _project.HolyGrail.ColorStrength, 0.05, "0.00", string.Empty,
+ "Quanta parte della correzione cromatica applicare.",
value => { _project.HolyGrail.ColorStrength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
- stack.Add(Note("Il valore di esposizione si legge nei metadati, quindi l'ampiezza di ogni salto " +
- "è nota senza incertezza. Un salto che la luminanza non ha recepito — fotogramma " +
- "già saturo — viene lasciato stare."));
-
return stack.Panel;
}
@@ -360,30 +690,50 @@ internal sealed class SettingsPanel : Panel
stack.Add(new SectionHeader("Stabilizzazione sub-pixel"));
- stack.Add(Check("Compensa i micro-urti", _project.Stabilization.Enabled, value =>
- {
- _project.Stabilization.Enabled = value;
- AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Compensa i micro-urti", _project.Stabilization.Enabled,
+ "Vento sul treppiede, passi vicino alla macchina, scatto dello specchio: sono " +
+ "spostamenti di pochi pixel che a velocità di time-lapse diventano un tremolio " +
+ "continuo. Lo spostamento si misura con la correlazione di fase, che ignora le " +
+ "differenze di luminosità fra scatti e reagisce alla sola geometria.",
+ value =>
+ {
+ _project.Stabilization.Enabled = value;
+ AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
+ }));
stack.Add(AutoSlider(AutoKey.StabilizationWindow, "Finestra del percorso", 5, 151,
_project.Stabilization.SmoothingFrames, 2, "0", "fotogrammi",
+ "Quanto un movimento deve durare per essere considerato voluto. Corta rimuove solo " +
+ "il tremolio; lunga blocca anche le panoramiche, e si paga in ritaglio. In " +
+ "automatico si sceglie la più corta che rende liscio il percorso misurato.",
value => { _project.Stabilization.SmoothingFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità", 0, 1, _project.Stabilization.Strength, 0.05, "0.00", string.Empty,
+ "Quanta parte della correzione applicare. Sotto 1 il tremolio si attenua invece di " +
+ "sparire, il che serve quando la correzione piena richiede un ritaglio eccessivo.",
value => { _project.Stabilization.Strength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Correzione massima", 0.005, 0.20, _project.Stabilization.MaxCorrectionFraction, 0.005,
"0.000", "×L",
+ "Limite della correzione, in frazione della larghezza. Protegge da una stima " +
+ "sbagliata: senza, un picco spurio della correlazione potrebbe spostare il " +
+ "fotogramma di mezza inquadratura. È anche ciò che determina quanto va ritagliato.",
value => { _project.Stabilization.MaxCorrectionFraction = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
- stack.Add(Check("Compensa anche la rotazione", _project.Stabilization.CompensateRotation, value =>
- {
- _project.Stabilization.CompensateRotation = value;
- AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Compensa anche la rotazione", _project.Stabilization.CompensateRotation,
+ "Il vento che fa torcere la testa del treppiede produce una rotazione residua, " +
+ "piccola ma visibile. Con nove riquadri per coppia si può stimare una similitudine " +
+ "completa invece della sola traslazione.",
+ value =>
+ {
+ _project.Stabilization.CompensateRotation = value;
+ AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
+ }));
stack.Add(Slider("Lato dei riquadri di correlazione", 64, 512, _project.Stabilization.PatchSize, 64, "0", "px",
+ "Un riquadro più largo contiene più tessitura e dà un picco più stretto, quindi " +
+ "una misura più precisa; costa però più trasformata. Il valore viene ricondotto " +
+ "alla potenza di due più vicina, che è ciò che la trasformata richiede.",
value =>
{
_project.Stabilization.PatchSize = Fourier.FloorPowerOfTwo((int)value);
@@ -391,15 +741,22 @@ internal sealed class SettingsPanel : Panel
}));
stack.Add(Slider("Riquadri per lato", 1, 5, _project.Stabilization.Grid, 1, "0", string.Empty,
+ "Tre significa nove misure indipendenti per coppia di fotogrammi, da cui si stima " +
+ "ai minimi quadrati una similitudine scartando i riquadri poco attendibili — " +
+ "tipicamente quelli occupati da nuvole o fronde, che si muovono per conto loro.",
value => { _project.Stabilization.Grid = (int)value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(new SectionHeader("Movimento di macchina virtuale"));
- stack.Add(Check("Panoramiche e zoom virtuali", _project.Camera.Enabled, value =>
- {
- _project.Camera.Enabled = value;
- AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Panoramiche e zoom virtuali", _project.Camera.Enabled,
+ "Un sensore da dodici megapixel contiene un 4K con abbondante margine di ritaglio: " +
+ "da lì si ricava un movimento che in ripresa avrebbe richiesto una slitta " +
+ "motorizzata. Il prezzo è la risoluzione consumata dallo zoom.",
+ value =>
+ {
+ _project.Camera.Enabled = value;
+ AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
+ }));
_cameraEditor = new CameraEditor { Settings = _project.Camera, Height = 200 };
_cameraEditor.Changed += (_, _) =>
@@ -408,6 +765,10 @@ internal sealed class SettingsPanel : Panel
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
};
_cameraEditor.SelectionChanged += (_, _) => SyncKeyframeControls();
+ Tips.Set(_cameraEditor,
+ "Le inquadrature si trascinano dentro il riquadro e si stringono dalla maniglia " +
+ "d'angolo; la striscia in basso è la linea del tempo. Le tacche lungo il percorso " +
+ "sono equispaziate nel tempo: dove si addensano il movimento rallenta.");
stack.Add(_cameraEditor);
var keyframeButtons = new Panel { Height = 32, BackColor = Theme.Surface };
@@ -420,14 +781,22 @@ internal sealed class SettingsPanel : Panel
stack.Add(keyframeButtons);
_keyframeCentreX = Slider("Nodo — centro orizzontale", 0, 1, 0.5, 0.005, "0.000", string.Empty,
+ "Posizione orizzontale del centro dell'inquadratura, da 0 (bordo sinistro) a 1.",
value => UpdateSelectedKeyframe(k => k.CentreX = value));
_keyframeCentreY = Slider("Nodo — centro verticale", 0, 1, 0.5, 0.005, "0.000", string.Empty,
+ "Posizione verticale del centro dell'inquadratura, da 0 (bordo superiore) a 1.",
value => UpdateSelectedKeyframe(k => k.CentreY = value));
_keyframeZoom = Slider("Nodo — ingrandimento", 1, 8, 1, 0.05, "0.00", "×",
+ "Fattore di ritaglio. Oltre un certo punto si vede il rumore del sensore invece " +
+ "del dettaglio: è la risoluzione nativa a stabilire quanto si può stringere.",
value => UpdateSelectedKeyframe(k => k.Zoom = value));
_keyframeEaseOut = Slider("Nodo — indugio in partenza", 0, 1, 0.42, 0.02, "0.00", string.Empty,
+ "Quanto il movimento indugia partendo da questo nodo. A 0 parte netto, che in un " +
+ "time-lapse si vede come uno scatto; il valore predefinito è la curva morbida " +
+ "simmetrica di uso comune.",
value => UpdateSelectedKeyframe(k => k.EaseOut = value));
_keyframeEaseIn = Slider("Nodo — frenata in arrivo", 0, 1, 0.42, 0.02, "0.00", string.Empty,
+ "Quanto il movimento rallenta arrivando su questo nodo.",
value => UpdateSelectedKeyframe(k => k.EaseIn = value));
stack.Add(_keyframeCentreX);
@@ -436,47 +805,72 @@ internal sealed class SettingsPanel : Panel
stack.Add(_keyframeEaseOut);
stack.Add(_keyframeEaseIn);
- stack.Add(Check("Tieni l'inquadratura dentro il fotogramma", _project.Camera.KeepInsideFrame, value =>
- {
- _project.Camera.KeepInsideFrame = value;
- PreviewInvalidated?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Tieni l'inquadratura dentro il fotogramma", _project.Camera.KeepInsideFrame,
+ "Impedisce al ritaglio di uscire dalla sorgente. Spegnendolo si può arrivare al " +
+ "bordo, ma oltre comparirebbe una fascia vuota.",
+ value =>
+ {
+ _project.Camera.KeepInsideFrame = value;
+ PreviewInvalidated?.Invoke(this, EventArgs.Empty);
+ }));
stack.Add(new SectionHeader("Motion blur sintetico"));
- stack.Add(Check("Sfocatura di movimento attiva", _project.MotionBlur.Enabled, value =>
- {
- _project.MotionBlur.Enabled = value;
- PreviewInvalidated?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Sfocatura di movimento attiva", _project.MotionBlur.Enabled,
+ "Sintetizza la scia che una posa più lunga avrebbe inciso. La scia già presente e " +
+ "quella sintetica si sommano in quadratura, quindi per raggiungere l'apertura " +
+ "obiettivo serve una scia di √(obiettivo² − reale²): sommare linearmente " +
+ "produrrebbe un'immagine sistematicamente troppo morbida.",
+ value =>
+ {
+ _project.MotionBlur.Enabled = value;
+ PreviewInvalidated?.Invoke(this, EventArgs.Empty);
+ }));
stack.Add(Slider("Shutter angle obiettivo", 0, 360, _project.MotionBlur.TargetShutterAngle, 5, "0", "°",
+ "La frazione dell'intervallo che si vuole risulti esposta. 180° è la convenzione " +
+ "cinematografica e dà la resa a cui l'occhio è abituato; valori più alti rendono " +
+ "il movimento più fluido e più molle.",
value => { _project.MotionBlur.TargetShutterAngle = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità", 0, 1, _project.MotionBlur.Strength, 0.05, "0.00", string.Empty,
+ "Quanta parte della sfocatura mancante sintetizzare.",
value => { _project.MotionBlur.Strength = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Lunghezza massima della scia", 4, 160, _project.MotionBlur.MaxBlurPixels, 2, "0", "px",
+ "Limite superiore della scia. Protegge dalle stime di movimento sbagliate: senza, " +
+ "un vettore errato trascinerebbe il pixel attraverso mezza immagine.",
value => { _project.MotionBlur.MaxBlurPixels = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Campioni per pixel", 3, 65, _project.MotionBlur.MaxSamples, 2, "0", string.Empty,
+ "Quanti campioni prendere lungo la scia. Pochi lasciano una scia a gradini; molti " +
+ "la rendono continua e costano tempo proporzionalmente.",
value => { _project.MotionBlur.MaxSamples = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(new SectionHeader("Campo vettoriale di movimento"));
stack.Add(Slider("Larghezza di analisi del movimento", 320, 1920, _project.Flow.AnalysisWidth, 32, "0", "px",
+ "A quanti pixel calcolare il campo. I vettori vengono poi riportati in scala piena, " +
+ "quindi il costo non dipende dalla dimensione dei file sorgente.",
value => { _project.Flow.AnalysisWidth = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Passo della griglia", 4, 32, _project.Flow.CellSize, 1, "0", "px",
+ "Distanza fra i nodi in cui il campo viene calcolato. Più fitta segue meglio i " +
+ "bordi dei soggetti in movimento e costa di più.",
value => { _project.Flow.CellSize = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Livelli della piramide", 1, 6, _project.Flow.PyramidLevels, 1, "0", string.Empty,
+ "Determina lo spostamento massimo inseguibile: ogni livello raddoppia la distanza " +
+ "che il campo riesce ad agganciare partendo dai livelli grossolani.",
value => { _project.Flow.PyramidLevels = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Raggio della finestra", 2, 12, _project.Flow.WindowRadius, 1, "0", "px",
+ "Semi-lato della finestra su cui si costruisce il sistema normale. Più larga è più " +
+ "stabile nelle zone poco strutturate e più imprecisa sui bordi.",
value => { _project.Flow.WindowRadius = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Iterazioni per livello", 1, 12, _project.Flow.Iterations, 1, "0", string.Empty,
+ "Quante volte raffinare la stima su ciascun livello prima di scendere al successivo.",
value => { _project.Flow.Iterations = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
SyncKeyframeControls();
@@ -516,6 +910,10 @@ internal sealed class SettingsPanel : Panel
"Adattiva — durata proporzionale all'intervallo",
"Interpolata — cadenza uniformata con fotogrammi sintetici"],
(int)_project.Export.Timing,
+ "Costante è il time-lapse classico: le pause dell'intervallometro diventano " +
+ "accelerazioni. Adattiva le lascia visibili allungando la durata dei fotogrammi. " +
+ "Interpolata riporta tutto su una griglia regolare sintetizzando i fotogrammi " +
+ "mancanti dal campo vettoriale.",
index =>
{
_project.Export.Timing = (FrameTimingMode)index;
@@ -523,15 +921,21 @@ internal sealed class SettingsPanel : Panel
}));
stack.Add(Slider("Dilatazione massima", 1.5, 8, _project.Export.MaxAdaptiveStretch, 0.5, "0.0", "×",
+ "Limite alla dilatazione in modalità adattiva: senza, una pausa lunga congelerebbe " +
+ "la scena per secondi.",
value => _project.Export.MaxAdaptiveStretch = value));
stack.Add(new SectionHeader("Rimappatura non lineare"));
- stack.Add(Check("Curva di velocità attiva", _project.TimeRamp.Enabled, value =>
- {
- _project.TimeRamp.Enabled = value;
- PreviewInvalidated?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Curva di velocità attiva", _project.TimeRamp.Enabled,
+ "Permette di accelerare le fasi statiche e rallentare quelle interessanti " +
+ "mantenendo fissa la velocità del filmato. I fotogrammi mancanti nei tratti " +
+ "rallentati vengono sintetizzati dal campo vettoriale.",
+ value =>
+ {
+ _project.TimeRamp.Enabled = value;
+ PreviewInvalidated?.Invoke(this, EventArgs.Empty);
+ }));
_rampEditor = new SplineEditor
{
@@ -547,17 +951,24 @@ internal sealed class SettingsPanel : Panel
_project.TimeRamp.Speed = [.. _rampEditor.Knots];
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
};
+ Tips.Set(_rampEditor,
+ "Trascina i nodi, aggiungine uno con un doppio clic, toglilo con il tasto destro.\n\n" +
+ "L'ordinata dice quanti scatti vengono consumati per ogni fotogramma d'uscita: " +
+ "sopra 1 la sequenza accelera saltando scatti, sotto 1 rallenta e i fotogrammi " +
+ "mancanti vengono sintetizzati. La curva è monotona per costruzione, quindi il " +
+ "tempo non può tornare indietro fra due nodi.");
stack.Add(_rampEditor);
- stack.Add(Note("Trascina i nodi, aggiungine uno con un doppio clic, toglilo con il tasto destro. " +
- "La curva dice quanti scatti vengono consumati per ogni fotogramma d'uscita: sopra 1 " +
- "la sequenza accelera, sotto 1 rallenta e i fotogrammi mancanti vengono sintetizzati."));
-
stack.Add(new SectionHeader("Accumulo temporale"));
stack.Add(Combo("Modalità",
["Nessuna", "Mediana — rimuove gli elementi di passaggio", "Massimo — scie stellari"],
_project.Stacking.Mode switch { StackingMode.Median => 1, StackingMode.Maximum => 2, _ => 0 },
+ "La mediana dà a ogni pixel il valore centrale della propria serie temporale: la " +
+ "scena stabile resta se stessa, chi attraversa l'inquadratura una volta sola " +
+ "sparisce. Una media lascerebbe un fantasma.\n\n" +
+ "Il massimo conserva il valore più alto incontrato e trasforma le stelle in archi " +
+ "continui.",
index =>
{
_project.Stacking.Mode = index switch
@@ -570,18 +981,20 @@ internal sealed class SettingsPanel : Panel
}));
stack.Add(Slider("Finestra della mediana", 3, 49, _project.Stacking.WindowFrames, 2, "0", "fotogrammi",
+ "Quanti fotogrammi mediare. Sono vivi tutti insieme in memoria, perché la mediana " +
+ "li vuole simultaneamente: una finestra larga su sorgenti da dodici megapixel " +
+ "occupa parecchio. Più larga rimuove intrusi più lenti.",
value => { _project.Stacking.WindowFrames = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità dell'accumulo", 0, 1, _project.Stacking.Strength, 0.05, "0.00", string.Empty,
+ "Quanta parte del risultato accumulato miscelare sul fotogramma originale.",
value => { _project.Stacking.Strength = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Lunghezza delle scie", 0, 400, _project.Stacking.TrailFrames, 5, "0", "fotogrammi",
+ "Vale per il massimo progressivo. A zero le scie non si spengono mai e il cielo si " +
+ "riempie; un valore finito le fa svanire e mantiene leggibile il paesaggio sotto.",
value => { _project.Stacking.TrailFrames = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
- stack.Add(Note("La mediana tiene il valore centrale della serie temporale di ogni pixel: la scena " +
- "stabile resta se stessa, chi attraversa l'inquadratura una volta sola sparisce. " +
- "Il massimo trasforma le stelle in archi; a zero le scie non si spengono mai."));
-
return stack.Panel;
}
@@ -591,16 +1004,76 @@ internal sealed class SettingsPanel : Panel
{
var stack = NewStack();
+ stack.Add(new SectionHeader("Risoluzione e inquadratura"));
+
+ stack.Add(Combo("Rapporto d'uscita",
+ ["Come la sorgente", "16:9 panoramico", "21:9 cinemascope", "4:3 classico", "1:1 quadrato",
+ "9:16 verticale"],
+ Math.Max(0, Array.FindIndex(AspectValues, a => Math.Abs(a - _project.Export.AspectRatio) < 0.001)),
+ "Cambiare rapporto non deforma l'immagine: il ritaglio viene preso con il nuovo " +
+ "rapporto dentro il fotogramma, quindi quello che si perde sono due fasce. Con il " +
+ "riquadro qui sotto si decide quali.",
+ index =>
+ {
+ _project.Export.AspectRatio = AspectValues[Math.Clamp(index, 0, AspectValues.Length - 1)];
+ if (_framingEditor is not null)
+ {
+ _framingEditor.OutputAspect = _project.OutputAspect;
+ _framingEditor.Invalidate();
+ }
+ AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
+ }));
+
+ stack.Add(Slider("Larghezza dell'uscita", 0, 7680, _project.Export.Width, 160, "0", "px",
+ "Larghezza del video finale; zero la deduce dalla risoluzione di lavoro. " +
+ "L'altezza segue il rapporto scelto. Il valore viene comunque ricondotto ai " +
+ "limiti del codec: un fotogramma oltre il livello supportato darebbe un file che " +
+ "nessun lettore comune apre.",
+ value =>
+ {
+ _project.Export.Width = (int)value;
+ _project.Export.Height = 0;
+ AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
+ }));
+
+ stack.Add(Check("Ritaglia una zona precisa", _project.Export.CropEnabled,
+ "Sceglie quale porzione del fotogramma finisce nel video. È indipendente dal " +
+ "movimento virtuale, che quando è attivo ha la precedenza: sono due modi di dire " +
+ "la stessa cosa, e usarli insieme ritaglierebbe due volte.",
+ value =>
+ {
+ _project.Export.CropEnabled = value;
+ AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
+ }));
+
+ var (nativeWidth, nativeHeight) = _project.ResolveNativeSize();
+ _framingEditor = new FramingEditor(_project.Export)
+ {
+ Height = 170,
+ SourceAspect = nativeWidth > 0 && nativeHeight > 0 ? nativeWidth / (double)nativeHeight : 4.0 / 3.0,
+ OutputAspect = _project.OutputAspect,
+ };
+ _framingEditor.Changed += (_, _) => PreviewInvalidated?.Invoke(this, EventArgs.Empty);
+ Tips.Set(_framingEditor,
+ "Il rettangolo chiaro è ciò che finisce nel video, con il rapporto scelto sopra. " +
+ "Si trascina per spostarlo e si stringe dalla maniglia d'angolo; un doppio clic " +
+ "torna all'inquadratura piena. Le linee dei terzi sono lì perché è per comporre " +
+ "che si sta guardando questo riquadro.");
+ stack.Add(_framingEditor);
+
stack.Add(new SectionHeader("Formato"));
stack.Add(Combo("Codec", ["H.264 / AVC", "H.265 / HEVC"], _project.Export.Codec == VideoCodec.H264 ? 0 : 1,
+ "H.264 è compatibile con qualunque lettore. HEVC ottiene la stessa qualità con " +
+ "circa il trenta per cento di bitrate in meno, ma non tutti i programmi lo aprono.",
index =>
{
_project.Export.Codec = index == 0 ? VideoCodec.H264 : VideoCodec.Hevc;
- PreviewInvalidated?.Invoke(this, EventArgs.Empty);
+ AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Combo("Profilo H.264", ["Baseline", "Main", "High"],
_project.Export.Profile switch { H264Profile.Baseline => 0, H264Profile.Main => 1, _ => 2 },
+ "High è la scelta giusta salvo vincoli di compatibilità con apparecchi vecchi.",
index => _project.Export.Profile = index switch
{
0 => H264Profile.Baseline,
@@ -609,28 +1082,37 @@ internal sealed class SettingsPanel : Panel
}));
stack.Add(Slider("Frame rate", 6, 120, _project.Export.FrameRate, 1, "0", "fps",
+ "Velocità del filmato finale. Non cambia quanti scatti vengono usati, solo quanto " +
+ "in fretta scorrono.",
value => { _project.Export.FrameRate = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Bitrate medio", 5, 250, _project.Export.BitrateMbps, 5, "0", "Mb/s",
+ "Quanti dati al secondo. Un cielo notturno sfumato è il contenuto che ne chiede di " +
+ "più: è lì che un bitrate basso produce il banding.",
value => { _project.Export.BitrateMbps = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intervallo fra fotogrammi chiave", 1, 10, _project.Export.KeyframeIntervalSeconds, 1, "0", "s",
+ "Ogni quanto inserire un fotogramma indipendente. Più fitti rendono lo " +
+ "scorrimento più reattivo e occupano più spazio.",
value => _project.Export.KeyframeIntervalSeconds = (int)value));
- stack.Add(new SectionHeader("Codifica"));
stack.Add(Check("Preferisci l'encoder hardware", _project.Export.PreferHardware,
+ "Usa l'acceleratore della scheda video quando c'è: è molto più veloce. " +
+ "Spegnendolo si passa all'encoder software, che a parità di bitrate può " +
+ "guadagnare un po' di qualità.",
value => _project.Export.PreferHardware = value));
stack.Add(new SectionHeader("Destinazione"));
+ Tips.Set(OutputPathBox,
+ "Percorso del file finale. Il video viene scritto in un unico flusso continuo: " +
+ "l'unico altro file che l'elaborazione può creare è il parcheggio temporaneo dei " +
+ "fotogrammi, che si cancella da sé.");
stack.Add(OutputPathBox);
var browse = new DarkButton { Text = "Scegli il file di destinazione…", Height = 32, Dock = DockStyle.Top };
browse.Click += (_, _) => BrowseOutputRequested?.Invoke(this, EventArgs.Empty);
stack.Add(browse);
- stack.Add(Note("Il video viene scritto in un unico flusso continuo. L'unico altro file che " +
- "l'elaborazione può creare è il parcheggio temporaneo, che si cancella da sé."));
-
return stack.Panel;
}
@@ -642,21 +1124,19 @@ internal sealed class SettingsPanel : Panel
stack.Add(new SectionHeader("All'apertura"));
- stack.Add(Check("Ricorda le impostazioni per cartella", _app.RememberPerFolder, value =>
- {
- _app.RememberPerFolder = value;
- AppSettingsChanged?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Ricorda le impostazioni per cartella", _app.RememberPerFolder,
+ "Riapre una sequenza già lavorata con i parametri con cui era stata lasciata.",
+ value => { _app.RememberPerFolder = value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
- stack.Add(Check("Analizza appena una sequenza è caricata", _app.AnalyzeOnLoad, value =>
- {
- _app.AnalyzeOnLoad = value;
- AppSettingsChanged?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Analizza appena una sequenza è caricata", _app.AnalyzeOnLoad,
+ "Fa partire da sé la passata di analisi dopo il caricamento. Comodo su sequenze " +
+ "corte, meno su migliaia di scatti dove conviene prima controllare le impostazioni.",
+ value => { _app.AnalyzeOnLoad = value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(new SectionHeader("Valori predefiniti dei nuovi progetti"));
stack.Add(Combo("Profilo di qualità", ["Bozza", "Standard", "Massima"], (int)_app.DefaultQuality,
+ "Con quale profilo nasce un progetto nuovo.",
index =>
{
_app.DefaultQuality = (QualityProfile)Math.Clamp(index, 0, 2);
@@ -664,49 +1144,47 @@ internal sealed class SettingsPanel : Panel
}));
stack.Add(Slider("Decodifiche simultanee", 1, 16, _app.DefaultDecodeParallelism, 1, "0", "thread",
+ "Valore iniziale delle decodifiche simultanee nei progetti nuovi.",
value => { _app.DefaultDecodeParallelism = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Tetto di memoria", 256, 32768, _app.DefaultMemoryBudgetMiB, 256, "0", "MiB",
+ "Valore iniziale del tetto di memoria nei progetti nuovi.",
value => { _app.DefaultMemoryBudgetMiB = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
- stack.Add(Check("Parcheggia su disco i fotogrammi in eccesso", _app.DefaultAllowDiskSpill, value =>
- {
- _app.DefaultAllowDiskSpill = value;
- AppSettingsChanged?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Parcheggia su disco i fotogrammi in eccesso", _app.DefaultAllowDiskSpill,
+ "Valore iniziale del parcheggio su disco nei progetti nuovi.",
+ value => { _app.DefaultAllowDiskSpill = value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(new SectionHeader("Durante l'esportazione"));
- stack.Add(Check("Chiedi conferma prima di sovrascrivere", _app.ConfirmOverwrite, value =>
- {
- _app.ConfirmOverwrite = value;
- AppSettingsChanged?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Chiedi conferma prima di sovrascrivere", _app.ConfirmOverwrite,
+ "Avvisa quando il file di destinazione esiste già.",
+ value => { _app.ConfirmOverwrite = value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
- stack.Add(Check("Mostra i fotogrammi mentre vengono codificati", _app.LivePreviewDuringExport, value =>
- {
- _app.LivePreviewDuringExport = value;
- AppSettingsChanged?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Mostra i fotogrammi mentre vengono codificati", _app.LivePreviewDuringExport,
+ "Non migliora il risultato di un pixel, ma permette di accorgersi dopo dieci " +
+ "secondi che qualcosa non va, invece che dopo mezz'ora.",
+ value => { _app.LivePreviewDuringExport = value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Aggiorna l'anteprima ogni", 1, 60, _app.LivePreviewEvery, 1, "0", "fotogrammi",
+ "Ogni quanti fotogrammi rinfrescare l'anteprima. La conversione avviene sul thread " +
+ "che sta codificando, quindi un valore basso toglie tempo al lavoro vero.",
value => { _app.LivePreviewEvery = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
- stack.Add(Check("Apri la cartella a esportazione conclusa", _app.RevealWhenFinished, value =>
- {
- _app.RevealWhenFinished = value;
- AppSettingsChanged?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Apri la cartella a esportazione conclusa", _app.RevealWhenFinished,
+ "Apre Esplora risorse sul file appena prodotto.",
+ value => { _app.RevealWhenFinished = value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(new SectionHeader("Riquadro sponsor"));
- stack.Add(Check("Mostra gli sponsor durante l'attesa", _app.ShowSponsors, value =>
- {
- _app.ShowSponsors = value;
- AppSettingsChanged?.Invoke(this, EventArgs.Empty);
- }));
+ stack.Add(Check("Mostra gli sponsor durante l'attesa", _app.ShowSponsors,
+ "Il riquadro compare soltanto nella scheda Esportazione, dove per forza di cose si " +
+ "aspetta. Gli annunci si leggono da una cartella locale: il programma non contatta " +
+ "alcun servizio, non invia identificativi e non registra i clic.",
+ value => { _app.ShowSponsors = value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Cambia annuncio ogni", 5, 180, _app.SponsorRotationSeconds, 5, "0", "s",
+ "Ogni quanti secondi passare all'annuncio successivo.",
value => { _app.SponsorRotationSeconds = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
var openFolder = new DarkButton { Text = "Apri la cartella delle campagne", Height = 30, Dock = DockStyle.Top };
@@ -725,18 +1203,11 @@ internal sealed class SettingsPanel : Panel
// Cartella non apribile: non c'è nulla di sensato da fare.
}
};
+ Tips.Set(openFolder,
+ "Apre la cartella del listino, creando un esempio commentato se non c'è ancora. " +
+ "Le campagne si aggiornano copiando file lì dentro.");
stack.Add(openFolder);
- stack.Add(Note("Gli annunci si leggono da una cartella locale: il programma non contatta alcun " +
- "servizio, non invia identificativi e non registra clic. Le campagne si " +
- "aggiornano copiando file dentro quella cartella, con un elenco chiamato " +
- "campagne.txt. Il riquadro compare soltanto nella scheda Esportazione, dove per " +
- "forza di cose si aspetta."));
-
- stack.Add(new SectionHeader("Dove stanno i file"));
- stack.Add(Note($"Impostazioni: {AppSettings.SettingsPath}"));
- stack.Add(Note($"Campagne: {_app.ResolvedSponsorFolder}"));
-
return stack.Panel;
}
@@ -792,7 +1263,7 @@ internal sealed class SettingsPanel : Panel
}
private static ParameterSlider Slider(string caption, double min, double max, double value, double step,
- string format, string unit, Action onChange)
+ string format, string unit, string tip, Action onChange)
{
var slider = new ParameterSlider
{
@@ -805,6 +1276,7 @@ internal sealed class SettingsPanel : Panel
};
slider.SetValueSilently(value);
slider.ValueChanged += (_, _) => onChange(slider.Value);
+ Tips.Set(slider, tip);
return slider;
}
@@ -813,9 +1285,12 @@ internal sealed class SettingsPanel : Panel
/// toccare il cursore passa il comando all'utente, e il pulsante lo restituisce.
///
private ParameterSlider AutoSlider(AutoKey key, string caption, double min, double max, double value,
- double step, string format, string unit, Action onChange)
+ double step, string format, string unit, string tip,
+ Action onChange)
{
- var slider = Slider(caption, min, max, value, step, format, unit, onChange);
+ var slider = Slider(caption, min, max, value, step, format, unit,
+ tip + "\n\nCon l'indicatore AUTO acceso il valore lo sceglie il programma; " +
+ "toccare il cursore ne passa il controllo a te.", onChange);
slider.AutoSupported = true;
slider.IsAuto = !_project.ManualParameters.Contains(key);
@@ -840,19 +1315,21 @@ internal sealed class SettingsPanel : Panel
return slider;
}
- private static DarkCheckBox Check(string caption, bool value, Action onChange)
+ private static DarkCheckBox Check(string caption, bool value, string tip, Action onChange)
{
var box = new DarkCheckBox { Text = caption, Checked = value };
box.CheckedChanged += (_, _) => onChange(box.Checked);
+ Tips.Set(box, tip);
return box;
}
- private static LabeledCombo Combo(string caption, string[] items, int selected, Action onChange)
+ private static LabeledCombo Combo(string caption, string[] items, int selected, string tip, Action onChange)
{
var row = new LabeledCombo(caption);
row.Combo.Items.AddRange(items);
row.Combo.SelectedIndex = Math.Clamp(selected, 0, items.Length - 1);
row.Combo.SelectedIndexChanged += (_, _) => onChange(row.Combo.SelectedIndex);
+ Tips.Set(row, tip);
return row;
}
diff --git a/Titano/UI/Tips.cs b/Titano/UI/Tips.cs
new file mode 100644
index 0000000..16577a0
--- /dev/null
+++ b/Titano/UI/Tips.cs
@@ -0,0 +1,72 @@
+using System.Drawing.Drawing2D;
+
+namespace Titano.UI;
+
+///
+/// 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.
+///
+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;
+ }
+
+ /// Associa una spiegazione a un controllo e a tutti i suoi figli.
+ 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);
+ }
+}
diff --git a/Titano/Video/ExportSettings.cs b/Titano/Video/ExportSettings.cs
index 5b177a3..6a9a9af 100644
--- a/Titano/Video/ExportSettings.cs
+++ b/Titano/Video/ExportSettings.cs
@@ -32,6 +32,19 @@ public sealed class ExportSettings
public int Width { get; set; }
public int Height { get; set; }
+ ///
+ /// 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.
+ ///
+ public double AspectRatio { get; set; }
+
+ /// Ritaglio fisso dell'inquadratura, indipendente dal movimento virtuale.
+ 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;
/// Bitrate medio in megabit al secondo.