From db21a31e56b33fa12ce4ba8f00ecd1167dd23939 Mon Sep 17 00:00:00 2001 From: Alberto Balbo Date: Tue, 25 Aug 2026 23:09:54 +0200 Subject: [PATCH] Scheda Archivio, ottimizzazione per sezione e spiegazioni a comparsa Aggiunge l'importazione da scheda di memoria e riordina l'interfaccia attorno a quello che ciascuna sezione serve a fare. L'importazione riconosce i supporti collegati, cerca nelle sottocartelle e prima di toccare il disco mostra il piano: quali cartelle nasceranno e che nome avranno i primi file. Un'importazione sbagliata su mille file non si annulla, e guardare prima costa un istante. Fa poi tre cose che a mano si sbagliano sempre: separa le sessioni sulla pausa fra due scatti, invece di lasciare piu' riprese mescolate in una cartella sola; costruisce i nomi da modelli con segnaposto, perche' un archivio si consulta anni dopo e il nome e' l'unica cosa leggibile senza aprire nulla; e rilegge quello che ha scritto confrontandone l'impronta, perche' una scheda che si scollega a meta' copia produce file della dimensione giusta e del contenuto troncato, e il danno si scopre mesi dopo. Sulla conversione in DNG va detto con precisione cosa si puo' e non si puo' fare. Un DNG vero contiene i valori del sensore prima dell'interpolazione cromatica; ottenerlo da un formato proprietario richiede la libreria del produttore, che il vincolo sulle dipendenze esclude. Quello che si ottiene in-house e' un DNG lineare - la specifica lo prevede, i pixel sono gia' interpolati, il file e' valido e apribile ovunque ma non restituisce la liberta' del grezzo. La copia resta quindi la scelta predefinita, e l'interfaccia lo dice invece di lasciarlo intuire. Il contenitore TIFF/DNG e' scritto a mano come il multiplexer MP4, e la verifica lo rilegge con il parser di questo stesso programma: due implementazioni indipendenti dello stesso formato, e al primo confronto e' saltato fuori un errore di dodici byte per voce nel calcolo degli scostamenti - la directory Exif finiva oltre il puntatore che la indicava. Le spiegazioni passano dalle note stampate ai suggerimenti a comparsa. Una nota sotto un cursore occupa spazio a chi la conosce gia', quindi deve restare corta; un suggerimento che appare solo quando serve non ha quel vincolo e puo' dire l'unica cosa che conta - perche' quel parametro esiste e cosa succede a spostarlo nel verso sbagliato. Ogni sezione guadagna un comando Ottimizza che rileva le impostazioni migliori per quella sola parte e riferisce cosa ha cambiato e perche'. Si distingue dal pilota automatico, che lavora di continuo sui parametri deducibili senza ambiguita': l'ottimizzazione si chiede a mano perche' accende e spegne interi moduli, e sostituire quelle scelte in silenzio sarebbe peggio che lasciarle sbagliate. Dove il dato manca non tira a indovinare: lo dice. L'uscita guadagna rapporto e ritaglio. Cambiare rapporto non deforma piu' l'immagine: il ritaglio viene preso con il nuovo rapporto dentro il fotogramma, il che ha anche corretto un difetto latente dello stadio geometrico, che con rapporti diversi fra sorgente e uscita stirava invece di tagliare. La zona da tenere si sceglie trascinandola. I comandi seguono la sezione: importazione e analisi compaiono solo dove servono. Un pulsante che non ha senso dove ci si trova non va disabilitato ma tolto, perche' disabilitato resta un ingombro che chiede perche' non funziona. Le preferenze dell'applicazione non mostrano piu' anteprima, riepilogo della sequenza ne' riga di stato che ne parli: non riguardano la sequenza caricata, e tenerle accanto confondeva due piani diversi. Via anche le descrizioni inutili: la finestra si chiama Titano e basta. Due difetti trovati durante la verifica a video. La barra di navigazione nasceva sulla prima voce, che ora e' Archivio, quindi il selettore usciva subito perche' l'indice coincideva e la sezione non veniva mai mostrata: barra su una voce, contenuto su un'altra. E il posizionamento dei pulsanti leggeva Visible, che in WinForms resta falso finche' la finestra non e' stata mostrata perche' riporta la visibilita' dell'intera catena: alla costruzione nessun pulsante veniva collocato e restavano tutti impilati sull'angolo. Verifica: da 54 a 60 controlli. I nuovi coprono i modelli di percorso, la sostituzione dei caratteri illegali, il rifiuto dei segnaposto inventati, il riconoscimento delle sessioni e la rilettura del DNG scritto - sia dal parser interno sia dal decodificatore di sistema. Co-Authored-By: Claude Opus 5 --- Titano/Diagnostics/AdvancedModuleTests.cs | 126 ++++ Titano/Imaging/GeometryStage.cs | 12 +- Titano/Imaging/RasterWriter.cs | 381 +++++++++++ Titano/Pipeline/AutoOptimizer.cs | 314 +++++++++ Titano/Pipeline/MediaImporter.cs | 497 ++++++++++++++ Titano/Pipeline/PathTemplate.cs | 186 ++++++ Titano/Pipeline/TitanoProject.cs | 37 +- Titano/Program.cs | 2 +- Titano/README.md | 64 +- Titano/UI/FramingEditor.cs | 215 ++++++ Titano/UI/ImportPanel.cs | 342 ++++++++++ Titano/UI/MainForm.cs | 250 ++++++- Titano/UI/NavigationRail.cs | 16 + Titano/UI/SettingsPanel.cs | 781 +++++++++++++++++----- Titano/UI/Tips.cs | 72 ++ Titano/Video/ExportSettings.cs | 13 + 16 files changed, 3109 insertions(+), 199 deletions(-) create mode 100644 Titano/Imaging/RasterWriter.cs create mode 100644 Titano/Pipeline/AutoOptimizer.cs create mode 100644 Titano/Pipeline/MediaImporter.cs create mode 100644 Titano/Pipeline/PathTemplate.cs create mode 100644 Titano/UI/FramingEditor.cs create mode 100644 Titano/UI/ImportPanel.cs create mode 100644 Titano/UI/Tips.cs 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.