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.