diff --git a/CLAUDE.md b/CLAUDE.md index 7a742ac..1eb504d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,12 +9,10 @@ Herramienta de IA aparte de Rodaje, con interfaz propia. Repositorio público `D - **Worktrees** en `.claude\worktrees\` (ignorado). Un commit por tarea y push. - **Módulos:** `Modules.Find` los busca, en orden, en `SUBTITULAR_MODULOS`, la carpeta del Lanzador (`%LOCALAPPDATA%\Dathinel\Comun\modulos\modulo-*\`, la más nueva), los de Rodaje (`%LOCALAPPDATA%\Rodaje\complementos\modulos\modulo-*`, `RODAJE_MODULOS`) y `modulos\` junto al exe (`ModulesFindTests`). Las pruebas largas (`RODAJE_PRUEBAS_LARGAS=1`) necesitan los tres módulos con sus modelos y el corpus FLEURS en `RODAJE_ENTORNO`. -## Estado (24-oct) +## Estado (31-oct) -- Fase 0 cerrada: esqueleto, licencia y `src\Subtitular.slnx`. -- Fase 1 cerrada: `Subtitular.Core` (decodificar, `Rodaje.Module.Subtitles`, SRT/VTT) y `Subtitular.Cli`; WER 2,13 % (`docs/informe-fase-1.md`). -- Fase 2 cerrada: ventana WinUI 3 con AOT, traducción y voz en Core y CLI (`docs/informe-fase-2.md`). -- Fase 3 cerrada y verificada (`docs/informe-fase-3.md`): `build\distribuir.ps1` deja en `dist\` el zip AOT autocontenido (26,2 MB), el instalador Inno Setup por usuario (16,8 MB), `catalogo-subtitular.json` (entrada `app`), `notas-version.md` y `SHA256SUMS`; `ci.yml` y `release.yml` (solo `workflow_dispatch`, `--draft`). Pendiente legal: términos del Windows App SDK (`docs/legal.md`). +- Fases 0-3 cerradas y verificadas (`docs/informe-fase-1.md` a `-3.md`): Core, CLI, ventana WinUI 3 con AOT, `build\distribuir.ps1` (zip AOT, instalador Inno Setup, `catalogo-subtitular.json`, `notas-version.md`, `SHA256SUMS`), `ci.yml` y `release.yml` (`workflow_dispatch`, `--draft`). Pendiente legal: términos del Windows App SDK (`docs/legal.md`). - Submódulo `externo/rodaje` fijado a PR #445 (`0436ad75`, `IndexEntryKind.App`). -- **1.0.0 publicada** el 24-oct: release `v1.0.0` en GitHub (zip, instalador y `SHA256SUMS`), compilación `-warnaserror` sin avisos, 34 pruebas en verde con la larga (WER 2,13 %). -- **Siguiente:** Daniel firma `dist\catalogo-subtitular.json` (URL y huella de la release) en `Dathinel/rodaje-catalogo`; luego 1.0.1 con lo que salga de `docs/pruebas-a-mano.md`. +- **1.0.0 publicada** el 24-oct: release `v1.0.0` en GitHub (zip, instalador y `SHA256SUMS`), `-warnaserror` sin avisos, 34 pruebas en verde con la larga (WER 2,13 %). +- **PR #1 unido en `main`** el 31-oct: cola de archivos, abrir la carpeta al terminar, atajos, preferencias y el arreglo de la CI (`Get-FileHash`). Todo está en «[Sin publicar]» del `CHANGELOG.md`; `Directory.Build.props` sigue en **1.0.0** a propósito: se sube a 1.0.1 y se cierra «[Sin publicar]» en el mismo commit que publique la 1.0.1. +- **Siguiente:** Daniel firma `dist\catalogo-subtitular.json` (URL y huella de la release) en `Dathinel/rodaje-catalogo`; las correcciones de la revisión de bloque (PR `claude/arreglos-revision`) y lo que salga de `docs/pruebas-a-mano.md` van a la 1.0.1. diff --git a/build/catalogo-entrada.ps1 b/build/catalogo-entrada.ps1 index 17886ef..8366e75 100644 --- a/build/catalogo-entrada.ps1 +++ b/build/catalogo-entrada.ps1 @@ -39,6 +39,8 @@ if (-not $Zip) { if (-not (Test-Path -LiteralPath $Zip)) { throw "No se encontró el archivo zip de distribución: $Zip (corre antes build\distribuir.ps1)." } +# Las clases de .NET resuelven una ruta relativa contra el directorio del proceso, no contra $PWD como Test-Path. +$Zip = (Resolve-Path -LiteralPath $Zip).Path # Huella con las clases de .NET y no con Get-FileHash: en Windows PowerShell 5.1 es una función de script de # Microsoft.PowerShell.Utility que solo existe si PSModulePath apunta a $PSHOME\Modules, y un proceso lanzado diff --git a/src/Subtitular.App/FileDialogs.cs b/src/Subtitular.App/FileDialogs.cs index 32c50c3..e440527 100644 --- a/src/Subtitular.App/FileDialogs.cs +++ b/src/Subtitular.App/FileDialogs.cs @@ -25,9 +25,6 @@ public static readonly (string Name, string Spec) MediaFilter = public static readonly (string Name, string Spec) AllFilter = ("Todos los archivos", "*.*"); - public static string? OpenSingle(nint owner, string title, params (string Name, string Spec)[] filters) => - OpenMany(owner, title, null, false, filters).FirstOrDefault(); - /// Uno o varios archivos (Ctrl o Mayús en el diálogo); vacío si se cancela. Empieza en si existe. public static IReadOnlyList OpenMany(nint owner, string title, string? initialDir, bool multi, params (string Name, string Spec)[] filters) { diff --git a/src/Subtitular.App/MainWindow.xaml.cs b/src/Subtitular.App/MainWindow.xaml.cs index 12c34e5..b67f88b 100644 --- a/src/Subtitular.App/MainWindow.xaml.cs +++ b/src/Subtitular.App/MainWindow.xaml.cs @@ -70,7 +70,7 @@ public MainWindow() { new WindowPlacement(AppWindow.Position.X, AppWindow.Position.Y, AppWindow.Size.Width, AppWindow.Size.Height).Save(WindowPlacement.DefaultPath); } - CurrentPreferences().Save(Preferences.DefaultPath); + SavePreferences(); }; VersionText.Text = "Versión " + (typeof(App).Assembly.GetName().Version?.ToString(3) ?? "?") + " · Copyright (C) 2026 Dathinel"; CueList.ItemsSource = _cues; @@ -93,6 +93,16 @@ public MainWindow() OpenFolderBox.IsChecked = _prefs.OpenFolder; UpdatesBox.IsChecked = _prefs.CheckUpdates; OutputDirBox.Text = _prefs.OutputDir ?? ""; + // Se guardan al cambiar, no solo al cerrar: un apagón o un cierre forzado no las pierde. + LanguageBox.SelectionChanged += (_, _) => SavePreferences(); + FormatBox.SelectionChanged += (_, _) => SavePreferences(); + TranslateBox.SelectionChanged += (_, _) => SavePreferences(); + OutputDirBox.TextChanged += (_, _) => SavePreferences(); + foreach (CheckBox box in new[] { SpeakBox, OpenFolderBox }) + { + box.Checked += (_, _) => SavePreferences(); + box.Unchecked += (_, _) => SavePreferences(); + } CompositionTarget.Rendering += OnFirstFrame; } @@ -210,6 +220,8 @@ private static void SelectTag(ComboBox box, string tag) box.SelectedIndex = index >= 0 ? index : 0; } + private void SavePreferences() => CurrentPreferences().Save(Preferences.DefaultPath); + private Preferences CurrentPreferences() => _prefs with { OutputDir = string.IsNullOrWhiteSpace(OutputDirBox.Text) ? null : OutputDirBox.Text, @@ -290,6 +302,7 @@ private async Task LoadFilesAsync(IReadOnlyList paths) _files.Clear(); _files.AddRange(valid); _prefs = _prefs with { LastInputDir = Path.GetDirectoryName(Path.GetFullPath(valid[0])) }; + SavePreferences(); _previewFile = null; _transcript = null; _cues.Clear(); @@ -371,6 +384,7 @@ private async void OnRun(object sender, RoutedEventArgs e) { ShowPreview(ok.Options.InputFile, last); } + // Cancelar no tira lo ya guardado: el resumen dice cuántos salieron y en cuál se paró. Show(results.All(r => r.Ok) ? InfoBarSeverity.Success : InfoBarSeverity.Warning, JobBatch.Summary(results)); OpenResultFolder([.. results.Where(r => r.Ok).SelectMany(r => new[] { r.Subtitles, r.Audio })]); } @@ -420,12 +434,7 @@ private void OpenResultFolder(IReadOnlyList outputs) /// El progreso de un solo archivo, en la misma barra que la cola. private static IProgress Single(IProgress progress, string file) => - new Relay(p => progress.Report(new BatchProgress(0, 1, file, p))); - - private sealed class Relay(Action report) : IProgress - { - public void Report(JobProgress value) => report(value); - } + new SyncProgress(p => progress.Report(new BatchProgress(0, 1, file, p))); /// Corre una etapa con progreso y Cancelar; los errores previstos salen en la barra, en español. private async Task BusyAsync(Func, CancellationToken, Task> work) @@ -462,7 +471,10 @@ private sealed class Relay(Action report) : IProgress try { T result = await work(progress, _cts.Token); - DoneStory.Begin(); + if (!_cts.IsCancellationRequested) + { + DoneStory.Begin(); + } return result; } catch (Exception ex) diff --git a/src/Subtitular.Core/JobBatch.cs b/src/Subtitular.Core/JobBatch.cs index a001425..60724dd 100644 --- a/src/Subtitular.Core/JobBatch.cs +++ b/src/Subtitular.Core/JobBatch.cs @@ -16,10 +16,10 @@ public string Status(TimeSpan elapsed) } } -/// Cómo acabó un archivo de la cola: sus salidas o su error (en español si era previsto). -public sealed record BatchResult(JobOptions Options, Transcript? Transcript, string? Subtitles, string? Audio, string? Error) +/// Cómo acabó un archivo de la cola: sus salidas, su error (en español si era previsto) o que se canceló antes de terminar. +public sealed record BatchResult(JobOptions Options, Transcript? Transcript, string? Subtitles, string? Audio, string? Error, bool Cancelled = false) { - public bool Ok => Error is null; + public bool Ok => Error is null && !Cancelled; } /// Varios archivos uno tras otro, con los mismos ajustes; cada uno se guarda al terminar. @@ -38,24 +38,48 @@ public static class JobBatch } /// - /// Procesa los archivos en orden. Un archivo que falla no para a los demás: queda con su error en el resultado. - /// Cancelar sí para la cola: el archivo en curso suelta su ffmpeg y su módulo (los matan sus propios - /// CancellationToken) y se lanza . + /// La cola sin repetidos (misma ruta completa) y sin que dos trabajos escriban la misma salida: el segundo «clip.mp4» + /// de otra carpeta con la carpeta de salida fijada pasa a «clip-2», el tercero a «clip-3». + /// + public static IReadOnlyList Prepare(IReadOnlyList jobs) + { + ArgumentNullException.ThrowIfNull(jobs); + List unique = [.. jobs.DistinctBy(j => Path.GetFullPath(j.InputFile), StringComparer.OrdinalIgnoreCase)]; + HashSet outputs = new(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < unique.Count; i++) + { + string name = Path.GetFileNameWithoutExtension(unique[i].InputFile); + for (int n = 2; !outputs.Add(SubtitleJob.OutputPath(unique[i], "")); n++) + { + unique[i] = unique[i] with { OutputName = $"{name}-{n}" }; + } + } + return unique; + } + + /// + /// Procesa los archivos en orden (tras ). Un archivo que falla no para a los demás: queda con su error en el resultado. + /// Cancelar sí para la cola: el archivo en curso suelta su ffmpeg y su módulo (los matan sus propios CancellationToken) + /// y vuelve, con los que faltaban, marcado ; lo ya guardado se devuelve igual. /// public static async Task> RunAsync(IReadOnlyList jobs, IProgress? progress = null, CancellationToken cancellationToken = default, Worker? worker = null) { - ArgumentNullException.ThrowIfNull(jobs); + jobs = Prepare(jobs); worker ??= RunAndSaveAsync; List results = new(jobs.Count); for (int i = 0; i < jobs.Count; i++) { - cancellationToken.ThrowIfCancellationRequested(); JobOptions options = jobs[i]; + if (cancellationToken.IsCancellationRequested) + { + results.Add(new BatchResult(options, null, null, null, null, Cancelled: true)); + continue; + } int index = i; progress?.Report(new BatchProgress(index, jobs.Count, options.InputFile, null)); // Relevo síncrono (no Progress, que encola): el de fuera ya lleva cada aviso a su hilo. - Relay stage = new(p => progress?.Report(new BatchProgress(index, jobs.Count, options.InputFile, p))); + SyncProgress stage = new(p => progress?.Report(new BatchProgress(index, jobs.Count, options.InputFile, p))); try { (Transcript transcript, string subtitles, string? audio) = await worker(options, stage, cancellationToken).ConfigureAwait(false); @@ -63,7 +87,7 @@ public static async Task> RunAsync(IReadOnlyList> RunAsync(IReadOnlyList report) : IProgress - { - public void Report(JobProgress value) => report(value); - } - - /// Resumen para la barra: «5 archivos listos» o «3 archivos listos, 2 con error: a.mp4, b.mkv». + /// + /// Resumen para la barra: «5 archivos listos», «3 archivos listos, 2 con error: a.mp4, b.mkv» o, si se canceló, + /// «3 de 5 listos, cancelado en d.mp4». + /// public static string Summary(IReadOnlyList results) { ArgumentNullException.ThrowIfNull(results); int ok = results.Count(r => r.Ok); - string listos = ok == 1 ? "1 archivo listo" : $"{ok} archivos listos"; - List failed = [.. results.Where(r => !r.Ok).Select(r => Path.GetFileName(r.Options.InputFile))]; - return failed.Count == 0 ? listos : $"{listos}, {failed.Count} con error: {string.Join(", ", failed)}"; + string ready = results.FirstOrDefault(r => r.Cancelled) is { } cancelled + ? $"{ok} de {results.Count} listos, cancelado en {Path.GetFileName(cancelled.Options.InputFile)}" + : ok == 1 ? "1 archivo listo" : $"{ok} archivos listos"; + List failed = [.. results.Where(r => r.Error is not null).Select(r => Path.GetFileName(r.Options.InputFile))]; + return failed.Count == 0 ? ready : $"{ready}, {failed.Count} con error: {string.Join(", ", failed)}"; } } +/// Avisa en el mismo hilo (no , que encola): quien lo recibe ya lleva cada aviso a su hilo. +public sealed class SyncProgress(Action report) : IProgress +{ + public void Report(T value) => report(value); +} + /// Dónde quedó el resultado, para abrirlo en el Explorador al terminar. public static class ResultFolder { diff --git a/src/Subtitular.Core/Preferences.cs b/src/Subtitular.Core/Preferences.cs index 6128de1..aeeb4f1 100644 --- a/src/Subtitular.Core/Preferences.cs +++ b/src/Subtitular.Core/Preferences.cs @@ -24,7 +24,10 @@ public sealed record Preferences( public static string DefaultPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dathinel", "Subtitular", "preferencias.json"); - /// Las guardadas, o las de fábrica si no hay archivo o está roto. Las carpetas que ya no existen se olvidan. + /// + /// Las guardadas, o las de fábrica si no hay archivo o está roto. Una carpeta que no exista en este arranque (disco externo o + /// de red desconectado) se conserva: el diálogo ya cae a la carpeta de siempre si no está. + /// public static Preferences Load(string path) { Preferences p; @@ -38,8 +41,8 @@ public static Preferences Load(string path) } return p with { - LastInputDir = ExistingDir(p.LastInputDir), - OutputDir = ExistingDir(p.OutputDir), + LastInputDir = string.IsNullOrWhiteSpace(p.LastInputDir) ? null : p.LastInputDir, + OutputDir = string.IsNullOrWhiteSpace(p.OutputDir) ? null : p.OutputDir, Language = string.IsNullOrWhiteSpace(p.Language) ? "auto" : p.Language, Format = p.Format is "srt" or "vtt" ? p.Format : "srt", TranslateTo = string.IsNullOrWhiteSpace(p.TranslateTo) ? null : p.TranslateTo, @@ -47,8 +50,6 @@ public static Preferences Load(string path) }; } - private static string? ExistingDir(string? dir) => !string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir) ? dir : null; - /// Si no se puede escribir (disco lleno, permisos) se pierde el recuerdo, no la sesión. public void Save(string path) { diff --git a/src/Subtitular.Core/SubtitleJob.cs b/src/Subtitular.Core/SubtitleJob.cs index 0e1d19e..ffcb51d 100644 --- a/src/Subtitular.Core/SubtitleJob.cs +++ b/src/Subtitular.Core/SubtitleJob.cs @@ -12,6 +12,8 @@ public sealed record JobOptions(string InputFile) public string Format { get; init; } = "srt"; /// Carpeta de salida; nula = junto al archivo de entrada. public string? OutputDir { get; init; } + /// Nombre base de las salidas; nulo = el del archivo de entrada. La cola lo usa para no pisar dos «clip.mp4». + public string? OutputName { get; init; } public string? TranslateTo { get; init; } public bool Speak { get; init; } public string Device { get; init; } = "auto"; @@ -85,7 +87,7 @@ public static string OutputPath(JobOptions options, string extension) { ArgumentNullException.ThrowIfNull(options); string dir = string.IsNullOrWhiteSpace(options.OutputDir) ? Path.GetDirectoryName(Path.GetFullPath(options.InputFile))! : options.OutputDir; - string name = Path.GetFileNameWithoutExtension(options.InputFile); + string name = options.OutputName ?? Path.GetFileNameWithoutExtension(options.InputFile); if (!string.IsNullOrWhiteSpace(options.TranslateTo)) { name += "." + options.TranslateTo; diff --git a/tests/Subtitular.Tests/BatchTests.cs b/tests/Subtitular.Tests/BatchTests.cs index ec45654..7622bbc 100644 --- a/tests/Subtitular.Tests/BatchTests.cs +++ b/tests/Subtitular.Tests/BatchTests.cs @@ -64,8 +64,45 @@ public async Task Cancel_stops_the_queue_at_the_current_file() await Task.Delay(Timeout.Infinite, token); return (Empty, "", null); }); - await Assert.ThrowsAnyAsync(() => run); + IReadOnlyList results = await run; Assert.Equal(1, started); + Assert.All(results, r => Assert.True(r.Cancelled && !r.Ok)); + Assert.Equal("0 de 2 listos, cancelado en a.mp4", JobBatch.Summary(results)); + } + + [Fact] + public async Task Cancel_keeps_the_files_already_saved() + { + using CancellationTokenSource cts = new(); + IReadOnlyList results = await JobBatch.RunAsync([Job("a.mp4"), Job("b.mp4"), Job("c.mp4")], null, cts.Token, (o, _, token) => + { + if (o.InputFile.EndsWith("b.mp4", StringComparison.Ordinal)) + { + cts.Cancel(); + token.ThrowIfCancellationRequested(); + } + return Task.FromResult((Empty, SubtitleJob.OutputPath(o, ".srt"), (string?)null)); + }); + Assert.Equal([true, false, false], results.Select(r => r.Ok)); + Assert.Equal(Path.Combine(Path.GetTempPath(), "a.srt"), results[0].Subtitles); + Assert.Equal("1 de 3 listos, cancelado en b.mp4", JobBatch.Summary(results)); + } + + [Fact] + public void Prepare_drops_repeated_inputs_and_renames_outputs_that_collide() + { + string outDir = Path.Combine(Path.GetTempPath(), "subs"); + JobOptions a = new(Path.Combine(Path.GetTempPath(), "a", "clip.mp4")) { OutputDir = outDir }; + JobOptions b = new(Path.Combine(Path.GetTempPath(), "b", "clip.mp4")) { OutputDir = outDir }; + JobOptions c = new(Path.Combine(Path.GetTempPath(), "c", "clip.mkv")) { OutputDir = outDir }; + IReadOnlyList prepared = JobBatch.Prepare([a, a with { Format = "vtt" }, b, c]); + + Assert.Equal(3, prepared.Count); + Assert.Equal(Path.Combine(outDir, "clip.srt"), SubtitleJob.OutputPath(prepared[0], ".srt")); + Assert.Equal(Path.Combine(outDir, "clip-2.srt"), SubtitleJob.OutputPath(prepared[1], ".srt")); + Assert.Equal(Path.Combine(outDir, "clip-3.srt"), SubtitleJob.OutputPath(prepared[2], ".srt")); + // Junto al video no chocan: cada uno queda en su carpeta con su nombre. + Assert.All(JobBatch.Prepare([a with { OutputDir = null }, b with { OutputDir = null }]), j => Assert.Null(j.OutputName)); } [Fact] diff --git a/tests/Subtitular.Tests/PreferencesTests.cs b/tests/Subtitular.Tests/PreferencesTests.cs index 6485763..cd844d8 100644 --- a/tests/Subtitular.Tests/PreferencesTests.cs +++ b/tests/Subtitular.Tests/PreferencesTests.cs @@ -5,7 +5,7 @@ namespace Subtitular.Tests; public sealed class PreferencesTests { [Fact] - public void Preferences_roundtrip_and_forget_missing_folders_and_broken_files() + public void Preferences_roundtrip_keep_missing_folders_and_survive_broken_files() { string dir = Path.Combine(Path.GetTempPath(), "subtitular-prefs-" + Guid.NewGuid().ToString("N")); string path = Path.Combine(dir, "preferencias.json"); @@ -28,12 +28,12 @@ public void Preferences_roundtrip_and_forget_missing_folders_and_broken_files() chosen.Save(path); Assert.Equal(chosen, Preferences.Load(path)); - // Una carpeta que ya no existe no se recuerda; un formato o idioma raros vuelven a los de fábrica. + // Una carpeta que no existe ahora (disco desconectado) se conserva; un formato o idioma raros vuelven a los de fábrica. string gone = Path.Combine(dir, "borrada"); new Preferences { LastInputDir = gone, OutputDir = gone, Language = " ", Format = "ass", TranslateTo = "", SkippedVersion = " " }.Save(path); Preferences loaded = Preferences.Load(path); - Assert.Null(loaded.LastInputDir); - Assert.Null(loaded.OutputDir); + Assert.Equal(gone, loaded.LastInputDir); + Assert.Equal(gone, loaded.OutputDir); Assert.Equal("auto", loaded.Language); Assert.Equal("srt", loaded.Format); Assert.Null(loaded.TranslateTo);