diff --git a/CHANGELOG.md b/CHANGELOG.md index 379cffa..cf15e35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,18 @@ - **Cola de archivos**: se pueden soltar o elegir varios videos o audios a la vez; se procesan uno tras otro con «Archivo 2 de 5 · nombre · etapa» en la barra, cada uno se guarda al terminar y un archivo que falla no para a los demás. Cancelar corta el archivo en curso y no deja procesos de FFmpeg ni del módulo vivos. - **Abrir la carpeta al terminar**: casilla (activada de fábrica) que abre el Explorador con el resultado seleccionado al guardar. - **Atajos**: `Ctrl+O` abre «Elegir…» y `Ctrl+Entrar` pulsa «Generar subtítulos». +- **Soltar una carpeta**: encola los videos y audios que contiene (por orden de nombre; no mira en sus subcarpetas). Una carpeta sin medios, un archivo que no lo es o algo que no está en el disco se avisan en la barra. +- **«Guardar sin revisar»**: casilla en Ajustes (apagada de fábrica, se recuerda) que guarda un solo archivo al terminar, sin pasar por la vista previa, igual que ya hacía la cola de varios. - **Aviso de versión nueva**: al arrancar, como mucho una vez cada 24 h, la ventana consulta la última release de `Dathinel/subtitular` en la API pública de GitHub (sin cuenta, `User-Agent` «Subtitular/», 5 s de límite, fuera del hilo de la interfaz); sin red o con una respuesta rara, no dice nada. Si hay una versión mayor, una franja discreta arriba dice «Subtitular 1.1.0 disponible» con «Ver novedades» (la release en el navegador), «Abrir el Lanzador» si está instalado (`%LOCALAPPDATA%\Dathinel\Lanzador` o `SUBTITULAR_LANZADOR`) y «Más tarde» (no vuelve a avisar de esa versión). En Ajustes, la casilla «Avisar cuando haya una versión nueva» (activada de fábrica) y «Buscar actualizaciones», que consulta al momento y también dice cuando no hay nada nuevo. La lógica (`UpdateCheck`, `ReleaseInfo`, `Launcher`) está en `Subtitular.Core` con pruebas sin red. ### Cambiado - La ventana recuerda la última carpeta (la del archivo elegido, para el diálogo, y la de salida), el idioma del audio, el formato, «Traducir a…», «Leer en voz alta» y la casilla de abrir la carpeta (`%LOCALAPPDATA%\Dathinel\Subtitular\preferencias.json`). Ahí mismo van la casilla del aviso de versión nueva, la hora de la última consulta y la versión descartada con «Más tarde». +- Las preferencias y la posición de la ventana se escriben de forma atómica (un `.tmp` que se renombra encima): un apagón a medias ya no deja un archivo roto. + ### Corregido +- Tras una cola (o con «Guardar sin revisar»), pulsar Guardar con «Leer en voz alta» volvía a generar el WAV aunque el texto no hubiera cambiado; ahora solo lo regenera si cambió alguna palabra (o el idioma), no por los cortes de bloque de la vista previa. +- `build\catalogo-entrada.ps1` con una `-Salida` relativa la escribía contra el directorio del proceso y no contra el actual. - La CI de `main` fallaba en `CatalogoEntradaTests`: `build\catalogo-entrada.ps1` dependía de `Get-FileHash`, que en Windows PowerShell 5.1 no existe si el proceso hereda un `PSModulePath` ajeno (el del host de pruebas lanzado desde pwsh). La huella se calcula ahora con las clases de .NET. ## [1.0.0] - 2026-10-24 diff --git a/build/catalogo-entrada.ps1 b/build/catalogo-entrada.ps1 index 8366e75..a87cebe 100644 --- a/build/catalogo-entrada.ps1 +++ b/build/catalogo-entrada.ps1 @@ -26,6 +26,11 @@ if (-not $Version) { if (-not $Salida) { $Salida = Join-Path $Raiz 'dist\catalogo-subtitular.json' } +# Una -Salida relativa va contra $PWD, como -Zip: [System.IO.File]::WriteAllText la resolvería contra el directorio del proceso +# (y Split-Path '' no da carpeta cuando es solo un nombre). Resolve-Path no sirve: el archivo aún no existe. +if (-not [System.IO.Path]::IsPathRooted($Salida)) { + $Salida = Join-Path (Get-Location).ProviderPath $Salida +} $dirSalida = Split-Path -Parent $Salida if (-not (Test-Path -LiteralPath $dirSalida)) { diff --git a/docs/pruebas-a-mano.md b/docs/pruebas-a-mano.md index 12c9b8b..e26b62c 100644 --- a/docs/pruebas-a-mano.md +++ b/docs/pruebas-a-mano.md @@ -35,6 +35,14 @@ Lo que la CI no puede comprobar (la lógica tiene pruebas en `BatchTests` y `Pre 4. **Abrir la carpeta.** Con la casilla marcada, al guardar un archivo se abre el Explorador con el `.srt` seleccionado; con varios, se abre la carpeta. Desmarcada, no se abre nada. 5. **Atajos.** `Ctrl+O` abre el diálogo de elegir; `Ctrl+Entrar` genera (y no hace nada mientras ya está generando o sin archivo). Con el foco dentro de un bloque de la vista previa, `Ctrl+Entrar` también debe generar y no meter un salto de línea. +## Revisión de bloque (carpetas, «Guardar sin revisar», voz) + +La lógica tiene pruebas en `MediaSelectionTests`, `PreferencesTests` y `SpeechTests`; esto es lo que solo se ve con la ventana. + +1. **Soltar una carpeta.** Arrastra una carpeta con tres videos, un `.txt` y una subcarpeta con otro video: la cabecera dice «3 archivos en cola» (los de la carpeta, por nombre; el de la subcarpeta no entra) y no sale ningún aviso por el `.txt` (dentro de una carpeta se ignora en silencio). Suelta una carpeta sin medios: barra roja «La carpeta «x» no tiene videos ni audios.» y la cola no cambia. Suelta a la vez un video suelto y una carpeta: entran los dos. Arrastra un adjunto desde Outlook o un archivo desde dentro de un zip abierto en el Explorador: barra roja «Lo que has soltado no es un archivo ni una carpeta del disco…» (o el cursor de «no se puede soltar», según lo que ofrezca el origen). +2. **Guardar sin revisar.** Marca la casilla, suelta un solo video y genera: al terminar no hay que pulsar Guardar; sale «Guardado: …srt» en verde, la vista previa queda rellena y, con la casilla de la carpeta, se abre el Explorador. Cierra y abre: la casilla sigue marcada. Desmárcala: vuelve el flujo de siempre («Revisa el texto y pulsa Guardar»). +3. **Guardar sin regenerar la voz.** Con «Leer en voz alta» y «Guardar sin revisar» (o una cola de dos), genera; al terminar pulsa Guardar sin tocar nada: debe guardar al momento (sin la etapa «Generando la voz») y la fecha del `.voz.wav` no cambia. Corrige una palabra y vuelve a Guardar: ahora sí regenera la voz y el WAV cambia. Vuelve a Guardar sin tocar nada: otra vez al momento. + ## Aviso de versión nueva La lógica (tag, comparación, 24 h, «Más tarde», Lanzador) tiene pruebas sin red en `UpdateCheckTests` y `LauncherTests`; esto es lo que solo se ve con la ventana y GitHub de verdad. Mientras la última release sea la 1.0.0 y esta compilación sea la 1.0.x, la franja no sale: para verla, baja `` en `Directory.Build.props` a `0.9.0` y publica. diff --git a/src/Subtitular.App/MainWindow.xaml b/src/Subtitular.App/MainWindow.xaml index e397fed..a24364e 100644 --- a/src/Subtitular.App/MainWindow.xaml +++ b/src/Subtitular.App/MainWindow.xaml @@ -93,7 +93,7 @@ - + @@ -125,6 +125,8 @@ + @@ -199,7 +201,7 @@ + Text="Suelta un video o un audio, elige el idioma y pulsa «Generar subtítulos» (Ctrl+Entrar). Podrás corregir cada bloque antes de guardarlo. Con varios archivos a la vez, o con «Guardar sin revisar», cada uno se guarda al terminar." /> diff --git a/src/Subtitular.App/MainWindow.xaml.cs b/src/Subtitular.App/MainWindow.xaml.cs index b67f88b..b93e168 100644 --- a/src/Subtitular.App/MainWindow.xaml.cs +++ b/src/Subtitular.App/MainWindow.xaml.cs @@ -40,6 +40,8 @@ private static readonly (string Id, string Name)[] AudioLanguages = /// El archivo al que pertenece la vista previa (con varios, el último que salió bien). private string? _previewFile; private Transcript? _transcript; + /// La transcripción de la que ya hay un WAV de voz (la cola o un Guardar anterior lo dejaron): Guardar no lo regenera si el texto no cambió. + private Transcript? _spoken; private CancellationTokenSource? _cts; private Preferences _prefs; /// La versión nueva que muestra la franja, y el Lanzador si está en el PC. @@ -91,6 +93,7 @@ public MainWindow() SelectTag(TranslateBox, _prefs.TranslateTo ?? ""); SpeakBox.IsChecked = _prefs.Speak; OpenFolderBox.IsChecked = _prefs.OpenFolder; + SkipReviewBox.IsChecked = _prefs.SaveWithoutReview; 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. @@ -98,7 +101,7 @@ public MainWindow() FormatBox.SelectionChanged += (_, _) => SavePreferences(); TranslateBox.SelectionChanged += (_, _) => SavePreferences(); OutputDirBox.TextChanged += (_, _) => SavePreferences(); - foreach (CheckBox box in new[] { SpeakBox, OpenFolderBox }) + foreach (CheckBox box in new[] { SpeakBox, OpenFolderBox, SkipReviewBox, UpdatesBox }) { box.Checked += (_, _) => SavePreferences(); box.Unchecked += (_, _) => SavePreferences(); @@ -230,16 +233,24 @@ private Preferences CurrentPreferences() => _prefs with TranslateTo = Tag(TranslateBox) is { Length: > 0 } t ? t : null, Speak = SpeakBox.IsChecked == true, OpenFolder = OpenFolderBox.IsChecked == true, + SaveWithoutReview = SkipReviewBox.IsChecked == true, CheckUpdates = UpdatesBox.IsChecked == true, }; private void OnDragOver(object sender, DragEventArgs e) => e.AcceptedOperation = e.DataView.Contains(StandardDataFormats.StorageItems) ? DataPackageOperation.Copy : DataPackageOperation.None; + /// Archivos y carpetas (de una carpeta entran sus videos y audios). Lo que no está en el disco (un adjunto, un elemento virtual) se avisa. private async void OnDrop(object sender, DragEventArgs e) => await GuardedAsync(async () => { IReadOnlyList items = await e.DataView.GetStorageItemsAsync(); - await LoadFilesAsync([.. items.OfType().Select(f => f.Path)]); + List paths = [.. items.Where(i => i is IStorageFile or IStorageFolder).Select(i => i.Path).Where(p => !string.IsNullOrWhiteSpace(p))]; + if (paths.Count == 0) + { + ShowError("Lo que has soltado no es un archivo ni una carpeta del disco. Suelta videos o audios, o una carpeta con ellos."); + return; + } + await LoadFilesAsync(paths); }); private nint Hwnd => WinRT.Interop.WindowNative.GetWindowHandle(this); @@ -275,7 +286,7 @@ private async Task GuardedAsync(Func action) } } - /// Carga la cola: uno o varios archivos. Los que no son video ni audio se avisan y se dejan fuera. + /// Carga la cola: archivos o carpetas (). Lo que no es video ni audio se avisa y se deja fuera. private async Task LoadFilesAsync(IReadOnlyList paths) { if (paths.Count == 0) @@ -288,13 +299,12 @@ private async Task LoadFilesAsync(IReadOnlyList paths) return; } Notice.IsOpen = false; - List valid = [.. paths.Where(p => MediaInfo.Extensions.Contains(Path.GetExtension(p).ToLowerInvariant()))]; - List rejected = [.. paths.Except(valid).Select(p => Path.GetFileName(p))]; - if (rejected.Count > 0) + MediaSelection selection = MediaInfo.Select(paths); + if (selection.Warning is { } warning) { - string names = string.Join(", ", rejected.Select(n => $"«{n}»")); - ShowError($"{names} {(rejected.Count == 1 ? "no es" : "no son")} un video ni un audio que Subtitular reconozca ({string.Join(", ", MediaInfo.Extensions)})."); + ShowError(warning); } + List valid = [.. selection.Media]; if (valid.Count == 0) { return; @@ -305,6 +315,7 @@ private async Task LoadFilesAsync(IReadOnlyList paths) SavePreferences(); _previewFile = null; _transcript = null; + _spoken = null; _cues.Clear(); SaveButton.IsEnabled = false; RunButton.IsEnabled = true; @@ -359,8 +370,9 @@ private async void OnRun(object sender, RoutedEventArgs e) _cues.Clear(); _transcript = null; _previewFile = null; + _spoken = null; SaveButton.IsEnabled = false; - if (_files.Count == 1) + if (SubtitleJob.ReviewsBeforeSaving(_files.Count, SkipReviewBox.IsChecked == true)) { string file = _files[0]; Transcript? transcript = await BusyAsync((progress, token) => SubtitleJob.RunAsync(Options(file), Single(progress, file), token)); @@ -368,12 +380,12 @@ private async void OnRun(object sender, RoutedEventArgs e) { return; } - ShowPreview(file, transcript); + ShowPreview(file, transcript, null); Show(InfoBarSeverity.Informational, $"{_cues.Count} bloques en «{transcript.Language}». Revisa el texto y pulsa Guardar."); return; } - // Varios: cada uno se transcribe y se guarda seguido; la vista previa queda con el último que salió bien. + // Varios (o uno con «Guardar sin revisar»): cada uno se transcribe y se guarda seguido; la vista previa queda con el último que salió bien. List jobs = [.. _files.Select(Options)]; IReadOnlyList? results = await BusyAsync((progress, token) => JobBatch.RunAsync(jobs, progress, token)); if (results is null) @@ -382,17 +394,26 @@ private async void OnRun(object sender, RoutedEventArgs e) } if (results.LastOrDefault(r => r.Ok) is { Transcript: { } last } ok) { - ShowPreview(ok.Options.InputFile, last); + ShowPreview(ok.Options.InputFile, last, ok.Audio is null ? null : 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)); + if (results is [{ Ok: true, Subtitles: { } subtitles } only]) + { + Show(InfoBarSeverity.Success, only.Audio is null ? $"Guardado: {subtitles}" : $"Guardados: {subtitles} y {only.Audio}"); + } + else + { + 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 })]); } - private void ShowPreview(string file, Transcript transcript) + /// : la transcripción de la que ya quedó el WAV de voz, si lo hay. + private void ShowPreview(string file, Transcript transcript, Transcript? spoken) { _previewFile = file; _transcript = transcript; + _spoken = spoken; foreach (SubtitleCue cue in SubtitlePreview.Cues(transcript)) { _cues.Add(new CueItem(cue)); @@ -408,27 +429,23 @@ private async void OnSave(object sender, RoutedEventArgs e) } string file = _previewFile; Transcript edited = SubtitlePreview.ToTranscript(_cues.Select(c => (c.Start, c.End, c.Text)), _transcript); - (string Subtitles, string? Audio)? saved = await BusyAsync((progress, token) => SubtitleJob.SaveAsync(edited, Options(file), Single(progress, file), token)); + Transcript? spoken = _spoken; + (string Subtitles, string? Audio)? saved = await BusyAsync((progress, token) => SubtitleJob.SaveAsync(edited, Options(file), Single(progress, file), token, spoken)); if (saved is { } s) { + // Si quedó un WAV, es el de este texto: otro Guardar sin cambios no lo vuelve a generar. + _spoken = s.Audio is null ? null : edited; Show(InfoBarSeverity.Success, s.Audio is null ? $"Guardado: {s.Subtitles}" : $"Guardados: {s.Subtitles} y {s.Audio}"); OpenResultFolder([s.Subtitles, s.Audio]); } } - /// Abre el Explorador en el resultado si la casilla está marcada; si no se puede, no pasa nada. + /// Abre el Explorador en el resultado si la casilla está marcada. private void OpenResultFolder(IReadOnlyList outputs) { - if (OpenFolderBox.IsChecked != true || ResultFolder.ExplorerCommand(outputs) is not { } command) - { - return; - } - try - { - Process.Start(new ProcessStartInfo(command.Exe, command.Arguments) { UseShellExecute = false })?.Dispose(); - } - catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) + if (OpenFolderBox.IsChecked == true) { + ResultFolder.Open(outputs); } } diff --git a/src/Subtitular.App/ResultFolder.cs b/src/Subtitular.App/ResultFolder.cs new file mode 100644 index 0000000..3313c74 --- /dev/null +++ b/src/Subtitular.App/ResultFolder.cs @@ -0,0 +1,41 @@ +using System.Diagnostics; + +namespace Subtitular.App; + +/// Dónde quedó el resultado, para abrirlo en el Explorador al terminar (cosa de la ventana, no de Core). +internal static class ResultFolder +{ + /// Carpeta común de las salidas; si están en varias, la de la primera. Nula sin salidas. + public static string? Of(IEnumerable outputs) => + outputs.Where(o => !string.IsNullOrWhiteSpace(o)).Select(o => Path.GetDirectoryName(Path.GetFullPath(o!))).FirstOrDefault(d => d is not null); + + /// + /// Orden para el Explorador: con un solo archivo lo abre seleccionado («/select,»); con varios o ninguno abre la carpeta. + /// Nula si no hay nada que abrir. + /// + public static (string Exe, string Arguments)? ExplorerCommand(IReadOnlyList outputs) + { + List files = [.. outputs.Where(o => !string.IsNullOrWhiteSpace(o)).Select(o => Path.GetFullPath(o!))]; + if (files.Count == 1) + { + return ("explorer.exe", "/select,\"" + files[0] + "\""); + } + return Of(files) is { } dir ? ("explorer.exe", "\"" + dir + "\"") : null; + } + + /// Abre el Explorador en el resultado; si no se puede, no pasa nada. + public static void Open(IReadOnlyList outputs) + { + if (ExplorerCommand(outputs) is not { } command) + { + return; + } + try + { + Process.Start(new ProcessStartInfo(command.Exe, command.Arguments) { UseShellExecute = false })?.Dispose(); + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) + { + } + } +} diff --git a/src/Subtitular.Core/JobBatch.cs b/src/Subtitular.Core/JobBatch.cs index 60724dd..6bec46b 100644 --- a/src/Subtitular.Core/JobBatch.cs +++ b/src/Subtitular.Core/JobBatch.cs @@ -118,29 +118,3 @@ 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 -{ - /// Carpeta común de las salidas; si están en varias, la de la primera. Nula sin salidas. - public static string? Of(IEnumerable outputs) - { - ArgumentNullException.ThrowIfNull(outputs); - return outputs.Where(o => !string.IsNullOrWhiteSpace(o)).Select(o => Path.GetDirectoryName(Path.GetFullPath(o!))).FirstOrDefault(d => d is not null); - } - - /// - /// Orden para el Explorador: con un solo archivo lo abre seleccionado («/select,»); con varios o ninguno abre la carpeta. - /// Nula si no hay nada que abrir. - /// - public static (string Exe, string Arguments)? ExplorerCommand(IReadOnlyList outputs) - { - ArgumentNullException.ThrowIfNull(outputs); - List files = [.. outputs.Where(o => !string.IsNullOrWhiteSpace(o)).Select(o => Path.GetFullPath(o!))]; - if (files.Count == 1) - { - return ("explorer.exe", "/select,\"" + files[0] + "\""); - } - return Of(files) is { } dir ? ("explorer.exe", "\"" + dir + "\"") : null; - } -} diff --git a/src/Subtitular.Core/JsonFile.cs b/src/Subtitular.Core/JsonFile.cs new file mode 100644 index 0000000..0927949 --- /dev/null +++ b/src/Subtitular.Core/JsonFile.cs @@ -0,0 +1,32 @@ +namespace Subtitular.Core; + +/// +/// Escritura atómica de los JSON de estado (preferencias, ventana): se escribe un «.tmp» al lado y se renombra encima, +/// así un apagón a medias deja el archivo anterior entero y no uno a medio escribir que al cargar se tomaría por roto. +/// +internal static class JsonFile +{ + /// Si no se puede escribir (disco lleno, permisos) se pierde el recuerdo, no la sesión: devuelve falso y no lanza. + public static bool Write(string path, byte[] bytes) + { + string temp = path + ".tmp"; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(temp, bytes); + File.Move(temp, path, overwrite: true); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + try + { + File.Delete(temp); + } + catch (Exception inner) when (inner is IOException or UnauthorizedAccessException) + { + } + return false; + } + } +} diff --git a/src/Subtitular.Core/MediaInfo.cs b/src/Subtitular.Core/MediaInfo.cs index 5855a71..c435eaa 100644 --- a/src/Subtitular.Core/MediaInfo.cs +++ b/src/Subtitular.Core/MediaInfo.cs @@ -3,12 +3,83 @@ namespace Subtitular.Core; +/// +/// Lo que se soltó o se eligió, repartido: los medios que van a la cola (los de una carpeta, por nombre), lo que no es un video +/// ni un audio y las carpetas que no tenían ninguno. +/// +public sealed record MediaSelection(IReadOnlyList Media, IReadOnlyList Rejected, IReadOnlyList EmptyFolders) +{ + /// Qué se dejó fuera y por qué, para la barra; nulo si entró todo. + public string? Warning + { + get + { + List parts = []; + if (Rejected.Count > 0) + { + string names = string.Join(", ", Rejected.Select(p => $"«{Path.GetFileName(p)}»")); + parts.Add($"{names} {(Rejected.Count == 1 ? "no es" : "no son")} un video ni un audio que Subtitular reconozca ({string.Join(", ", MediaInfo.Extensions)})."); + } + if (EmptyFolders.Count > 0) + { + string names = string.Join(", ", EmptyFolders.Select(p => $"«{Path.GetFileName(Path.TrimEndingDirectorySeparator(p))}»")); + parts.Add(EmptyFolders.Count == 1 ? $"La carpeta {names} no tiene videos ni audios." : $"Las carpetas {names} no tienen videos ni audios."); + } + return parts.Count == 0 ? null : string.Join(" ", parts); + } + } +} + /// Lo que la ventana muestra al soltar un archivo: duración y subtítulos que ya hay al lado. public static class MediaInfo { public static readonly IReadOnlyList Extensions = [".mp4", ".mkv", ".mov", ".m4v", ".webm", ".avi", ".mts", ".m2ts", ".ts", ".wmv", ".flv", ".3gp", ".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg", ".opus"]; + public static bool IsMedia(string path) => Extensions.Contains(Path.GetExtension(path).ToLowerInvariant()); + + /// + /// Reparte archivos y carpetas: de una carpeta entran sus videos y audios por orden de nombre (no los de sus subcarpetas); + /// un archivo que no es un medio queda en y una carpeta sin ninguno en + /// . Una carpeta que no se puede leer cuenta como vacía. + /// + public static MediaSelection Select(IReadOnlyList paths) + { + ArgumentNullException.ThrowIfNull(paths); + List media = []; + List rejected = []; + List emptyFolders = []; + foreach (string path in paths) + { + if (Directory.Exists(path)) + { + List inside; + try + { + inside = [.. Directory.EnumerateFiles(path).Where(IsMedia).OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase)]; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + inside = []; + } + if (inside.Count == 0) + { + emptyFolders.Add(path); + } + media.AddRange(inside); + } + else if (IsMedia(path)) + { + media.Add(path); + } + else + { + rejected.Add(path); + } + } + return new MediaSelection(media, rejected, emptyFolders); + } + /// Duración por ffprobe (junto a ffmpeg), o nula si no la da. public static async Task DurationAsync(string file, string? ffmpegPath = null, CancellationToken cancellationToken = default) { diff --git a/src/Subtitular.Core/Preferences.cs b/src/Subtitular.Core/Preferences.cs index aeeb4f1..4de88a7 100644 --- a/src/Subtitular.Core/Preferences.cs +++ b/src/Subtitular.Core/Preferences.cs @@ -5,7 +5,8 @@ namespace Subtitular.Core; /// /// Lo último que eligió el usuario en la ventana; se guarda en %LOCALAPPDATA%\Dathinel\Subtitular\preferencias.json. /// LastInputDir es la carpeta del último archivo elegido (el diálogo «Elegir…» vuelve a abrirse ahí); OutputDir nula = junto al video; -/// OpenFolder abre el Explorador con el resultado al guardar. El aviso de versión nueva: CheckUpdates lo enciende, +/// OpenFolder abre el Explorador con el resultado al guardar; SaveWithoutReview guarda un solo archivo al terminar, sin pasar +/// por la vista previa (como ya hace la cola). El aviso de versión nueva: CheckUpdates lo enciende, /// LastUpdateCheck es la última consulta a GitHub (como mucho una cada 24 h) y SkippedVersion la que se descartó con «Más tarde». /// Constructor primario con valores por omisión: una propiedad que falte en un archivo viejo toma el suyo al deserializar. /// @@ -17,6 +18,7 @@ public sealed record Preferences( string? TranslateTo = null, bool Speak = false, bool OpenFolder = true, + bool SaveWithoutReview = false, bool CheckUpdates = true, DateTimeOffset? LastUpdateCheck = null, string? SkippedVersion = null) @@ -50,16 +52,6 @@ public static Preferences Load(string path) }; } - /// Si no se puede escribir (disco lleno, permisos) se pierde el recuerdo, no la sesión. - public void Save(string path) - { - try - { - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.WriteAllBytes(path, JsonSerializer.SerializeToUtf8Bytes(this, SubtitularJsonContext.Default.Preferences)); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - } - } + /// Escritura atómica (); si no se puede escribir se pierde el recuerdo, no la sesión. + public void Save(string path) => JsonFile.Write(path, JsonSerializer.SerializeToUtf8Bytes(this, SubtitularJsonContext.Default.Preferences)); } diff --git a/src/Subtitular.Core/SpeechSynthesizer.cs b/src/Subtitular.Core/SpeechSynthesizer.cs index 1f6d8b0..7685faf 100644 --- a/src/Subtitular.Core/SpeechSynthesizer.cs +++ b/src/Subtitular.Core/SpeechSynthesizer.cs @@ -26,6 +26,20 @@ public static string VoiceLanguage(string? transcriptLanguage) }; } + /// + /// Si la voz leería lo mismo: el mismo idioma y las mismas palabras, segmento a segmento, sin contar los vacíos ni los espacios + /// de más. Los cortes de bloque no cuentan: una corrección de texto en la vista previa sí. + /// + public static bool SameSpeech(Transcript a, Transcript b) + { + ArgumentNullException.ThrowIfNull(a); + ArgumentNullException.ThrowIfNull(b); + return string.Equals(a.Language, b.Language, StringComparison.OrdinalIgnoreCase) && string.Equals(SpokenText(a), SpokenText(b), StringComparison.Ordinal); + } + + private static string SpokenText(Transcript t) => + string.Join(' ', t.Segments.Where(s => !string.IsNullOrWhiteSpace(s.Text)).SelectMany(s => s.Text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))); + /// Audio mono f32 con cada segmento colocado en su tiempo, y su frecuencia de muestreo. public static async Task<(float[] Audio, int SampleRate)> SpeakAsync( Transcript transcript, diff --git a/src/Subtitular.Core/SubtitleJob.cs b/src/Subtitular.Core/SubtitleJob.cs index ffcb51d..d18dc5b 100644 --- a/src/Subtitular.Core/SubtitleJob.cs +++ b/src/Subtitular.Core/SubtitleJob.cs @@ -126,9 +126,16 @@ public static async Task RunAsync(JobOptions options, IProgressEscribe los subtítulos y, si se pidió, el WAV con la voz. Devuelve las rutas escritas. + /// Un solo archivo pasa por la vista previa antes de guardarse, salvo con «Guardar sin revisar»; la cola se guarda sola. + public static bool ReviewsBeforeSaving(int files, bool saveWithoutReview) => files == 1 && !saveWithoutReview; + + /// + /// Escribe los subtítulos y, si se pidió, el WAV con la voz. Devuelve las rutas escritas. + /// es la transcripción de la que ya se generó el WAV (la cola lo deja hecho): si la voz leería lo mismo + /// () y el WAV sigue ahí, no se vuelve a generar. + /// public static async Task<(string Subtitles, string? Audio)> SaveAsync(Transcript transcript, JobOptions options, IProgress? progress = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, Transcript? spoken = null) { ArgumentNullException.ThrowIfNull(transcript); ArgumentNullException.ThrowIfNull(options); @@ -146,15 +153,20 @@ public static async Task RunAsync(JobOptions options, IProgress stages = StagesOf(options); + if (spoken is not null && File.Exists(wav) && SpeechSynthesizer.SameSpeech(transcript, spoken)) { - IReadOnlyList stages = StagesOf(options); - (float[] audio, int sampleRate) = await SpeechSynthesizer.SpeakAsync(transcript, options.VoiceModule, null, - new Progress(p => progress?.Report(JobProgress.Of(stages, JobStage.Speak, p.Fraction))), cancellationToken).ConfigureAwait(false); - wav = OutputPath(options, ".voz.wav"); - SpeechSynthesizer.WriteWav(wav, audio, sampleRate); + progress?.Report(JobProgress.Of(stages, JobStage.Speak, 1)); + return (subtitles, wav); } + (float[] audio, int sampleRate) = await SpeechSynthesizer.SpeakAsync(transcript, options.VoiceModule, null, + new Progress(p => progress?.Report(JobProgress.Of(stages, JobStage.Speak, p.Fraction))), cancellationToken).ConfigureAwait(false); + SpeechSynthesizer.WriteWav(wav, audio, sampleRate); return (subtitles, wav); } diff --git a/src/Subtitular.Core/WindowPlacement.cs b/src/Subtitular.Core/WindowPlacement.cs index 168d1b0..6486dfb 100644 --- a/src/Subtitular.Core/WindowPlacement.cs +++ b/src/Subtitular.Core/WindowPlacement.cs @@ -25,16 +25,6 @@ public sealed record WindowPlacement(int X, int Y, int Width, int Height) } } - /// Si no se puede escribir (disco lleno, permisos) se pierde el recuerdo, no la sesión. - public void Save(string path) - { - try - { - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.WriteAllBytes(path, JsonSerializer.SerializeToUtf8Bytes(this, SubtitularJsonContext.Default.WindowPlacement)); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - } - } + /// Escritura atómica (); si no se puede escribir se pierde el recuerdo, no la sesión. + public void Save(string path) => JsonFile.Write(path, JsonSerializer.SerializeToUtf8Bytes(this, SubtitularJsonContext.Default.WindowPlacement)); } diff --git a/tests/Subtitular.Tests/BatchTests.cs b/tests/Subtitular.Tests/BatchTests.cs index 7622bbc..f19d253 100644 --- a/tests/Subtitular.Tests/BatchTests.cs +++ b/tests/Subtitular.Tests/BatchTests.cs @@ -114,17 +114,4 @@ public void Single_file_status_has_no_queue_prefix_and_starts_as_preparing() JobProgress p = JobProgress.Of([JobStage.Decode, JobStage.Transcribe], JobStage.Transcribe, 0.5); Assert.Equal(p.Status(TimeSpan.FromSeconds(30)), new BatchProgress(0, 1, "clip.mp4", p).Status(TimeSpan.FromSeconds(30))); } - - [Fact] - public void Result_folder_opens_the_file_selected_or_the_common_folder() - { - string dir = Path.GetTempPath(); - string srt = Path.Combine(dir, "clip.srt"); - string wav = Path.Combine(dir, "clip.voz.wav"); - Assert.Null(ResultFolder.Of([])); - Assert.Null(ResultFolder.ExplorerCommand([null])); - Assert.Equal(Path.TrimEndingDirectorySeparator(dir), ResultFolder.Of([srt, null, wav])); - Assert.Equal<(string, string)?>(("explorer.exe", "/select,\"" + srt + "\""), ResultFolder.ExplorerCommand([srt, null])); - Assert.Equal<(string, string)?>(("explorer.exe", "\"" + Path.TrimEndingDirectorySeparator(dir) + "\""), ResultFolder.ExplorerCommand([srt, wav])); - } } diff --git a/tests/Subtitular.Tests/CatalogoEntradaTests.cs b/tests/Subtitular.Tests/CatalogoEntradaTests.cs index 0480e3d..5d44af8 100644 --- a/tests/Subtitular.Tests/CatalogoEntradaTests.cs +++ b/tests/Subtitular.Tests/CatalogoEntradaTests.cs @@ -22,40 +22,54 @@ private static string FindRepoRoot() throw new InvalidOperationException("No se encontró la raíz del repositorio."); } + private static string MakeZip(string temp) + { + string zipPath = Path.Combine(temp, "Subtitular-9.9.9-win-x64.zip"); + using (ZipArchive zip = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + using (Stream contenido = zip.CreateEntry("README.txt").Open()) + { + contenido.Write("prueba"u8); + } + return zipPath; + } + + /// Windows PowerShell en Windows (el que usa la CI); en otro sistema, pwsh, para probar el script también ahí. + private static void RunScript(string zipPath, string salida, string? workingDirectory) + { + var psi = new ProcessStartInfo(OperatingSystem.IsWindows() ? "powershell" : "pwsh") { RedirectStandardOutput = true, RedirectStandardError = true }; + if (workingDirectory is not null) + { + psi.WorkingDirectory = workingDirectory; + } + // Windows PowerShell 5.1 arma su PSModulePath por omisión solo si no lo hereda: el del host de pruebas + // (lanzado desde pwsh en la CI) no le servía y sus módulos propios no cargaban. + psi.Environment.Remove("PSModulePath"); + psi.ArgumentList.Add("-NoProfile"); + psi.ArgumentList.Add("-ExecutionPolicy"); + psi.ArgumentList.Add("Bypass"); + psi.ArgumentList.Add("-File"); + psi.ArgumentList.Add(Path.Combine(FindRepoRoot(), "build", "catalogo-entrada.ps1")); + psi.ArgumentList.Add("-Version"); + psi.ArgumentList.Add("9.9.9"); + psi.ArgumentList.Add("-Zip"); + psi.ArgumentList.Add(zipPath); + psi.ArgumentList.Add("-Salida"); + psi.ArgumentList.Add(salida); + using Process proceso = Process.Start(psi)!; + string texto = proceso.StandardOutput.ReadToEnd() + proceso.StandardError.ReadToEnd(); + proceso.WaitForExit(); + Assert.True(proceso.ExitCode == 0, texto); + } + [Fact] public void Catalogo_entrada_tiene_la_huella_real_del_zip_y_pasa_por_SignedIndex() { - string root = FindRepoRoot(); string temp = Directory.CreateTempSubdirectory("subtitular-catalogo-").FullName; try { - string zipPath = Path.Combine(temp, "Subtitular-9.9.9-win-x64.zip"); - using (ZipArchive zip = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - using (Stream contenido = zip.CreateEntry("README.txt").Open()) - { - contenido.Write("prueba"u8); - } + string zipPath = MakeZip(temp); string jsonPath = Path.Combine(temp, "catalogo.json"); - - var psi = new ProcessStartInfo("powershell") { RedirectStandardOutput = true, RedirectStandardError = true }; - // Windows PowerShell 5.1 arma su PSModulePath por omisión solo si no lo hereda: el del host de pruebas - // (lanzado desde pwsh en la CI) no le servía y sus módulos propios no cargaban. - psi.Environment.Remove("PSModulePath"); - psi.ArgumentList.Add("-NoProfile"); - psi.ArgumentList.Add("-ExecutionPolicy"); - psi.ArgumentList.Add("Bypass"); - psi.ArgumentList.Add("-File"); - psi.ArgumentList.Add(Path.Combine(root, "build", "catalogo-entrada.ps1")); - psi.ArgumentList.Add("-Version"); - psi.ArgumentList.Add("9.9.9"); - psi.ArgumentList.Add("-Zip"); - psi.ArgumentList.Add(zipPath); - psi.ArgumentList.Add("-Salida"); - psi.ArgumentList.Add(jsonPath); - using Process proceso = Process.Start(psi)!; - string salida = proceso.StandardOutput.ReadToEnd() + proceso.StandardError.ReadToEnd(); - proceso.WaitForExit(); - Assert.True(proceso.ExitCode == 0, salida); + RunScript(zipPath, jsonPath, null); string expectedSha256 = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(zipPath))); long expectedSize = new FileInfo(zipPath).Length; @@ -85,4 +99,30 @@ public void Catalogo_entrada_tiene_la_huella_real_del_zip_y_pasa_por_SignedIndex Directory.Delete(temp, recursive: true); } } + + /// + /// Una -Salida relativa (solo un nombre o con subcarpeta) va contra el directorio actual, como -Zip; antes + /// WriteAllText la resolvía contra el directorio del proceso y, con solo un nombre, Split-Path no daba carpeta. + /// + [Fact] + public void Salida_relativa_va_contra_el_directorio_actual() + { + string temp = Directory.CreateTempSubdirectory("subtitular-catalogo-rel-").FullName; + try + { + string zipPath = MakeZip(temp); + RunScript(zipPath, "catalogo.json", temp); + RunScript(Path.GetFileName(zipPath), Path.Combine("salida", "sub", "catalogo.json"), temp); + foreach (string relative in new[] { "catalogo.json", Path.Combine("salida", "sub", "catalogo.json") }) + { + using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(temp, relative), Encoding.UTF8)); + Assert.Equal("9.9.9", doc.RootElement.GetProperty("version").GetString()); + } + Assert.False(File.Exists(Path.Combine(FindRepoRoot(), "catalogo.json"))); + } + finally + { + Directory.Delete(temp, recursive: true); + } + } } diff --git a/tests/Subtitular.Tests/MediaSelectionTests.cs b/tests/Subtitular.Tests/MediaSelectionTests.cs new file mode 100644 index 0000000..330dfdc --- /dev/null +++ b/tests/Subtitular.Tests/MediaSelectionTests.cs @@ -0,0 +1,67 @@ +using Subtitular.Core; + +namespace Subtitular.Tests; + +public sealed class MediaSelectionTests +{ + [Fact] + public void A_folder_enqueues_its_media_by_name_without_subfolders() + { + string dir = Directory.CreateTempSubdirectory("subtitular-carpeta-").FullName; + try + { + string folder = Path.Combine(dir, "temporada"); + Directory.CreateDirectory(Path.Combine(folder, "extras")); + foreach (string name in new[] { "b.mkv", "A.mp4", "notas.txt", "c.MP3" }) + { + File.WriteAllText(Path.Combine(folder, name), ""); + } + File.WriteAllText(Path.Combine(folder, "extras", "d.mp4"), ""); + string loose = Path.Combine(dir, "suelto.wav"); + File.WriteAllText(loose, ""); + + MediaSelection one = MediaInfo.Select([folder]); + Assert.Equal(["A.mp4", "b.mkv", "c.MP3"], one.Media.Select(Path.GetFileName)); + Assert.Empty(one.Rejected); + Assert.Empty(one.EmptyFolders); + Assert.Null(one.Warning); + + // Archivos y carpetas mezclados, en el orden en que se soltaron; lo que no es un medio se avisa y se deja fuera. + MediaSelection mixed = MediaInfo.Select([loose, Path.Combine(dir, "leeme.txt"), folder]); + Assert.Equal(["suelto.wav", "A.mp4", "b.mkv", "c.MP3"], mixed.Media.Select(Path.GetFileName)); + Assert.Equal([Path.Combine(dir, "leeme.txt")], mixed.Rejected); + Assert.StartsWith("«leeme.txt» no es un video ni un audio", mixed.Warning, StringComparison.Ordinal); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Folders_without_media_are_reported_and_nothing_is_enqueued() + { + string dir = Directory.CreateTempSubdirectory("subtitular-vacia-").FullName; + try + { + string empty = Path.Combine(dir, "vacía"); + Directory.CreateDirectory(empty); + string docs = Path.Combine(dir, "docs"); + Directory.CreateDirectory(docs); + File.WriteAllText(Path.Combine(docs, "guion.docx"), ""); + + MediaSelection selection = MediaInfo.Select([empty, docs + Path.DirectorySeparatorChar, Path.Combine(dir, "foto.png")]); + Assert.Empty(selection.Media); + Assert.Equal([empty, docs + Path.DirectorySeparatorChar], selection.EmptyFolders); + Assert.Equal("«foto.png» no es un video ni un audio que Subtitular reconozca (" + string.Join(", ", MediaInfo.Extensions) + ")." + + " Las carpetas «vacía», «docs» no tienen videos ni audios.", selection.Warning); + Assert.Equal("La carpeta «vacía» no tiene videos ni audios.", MediaInfo.Select([empty]).Warning); + Assert.Equal("«a.txt», «b.doc» no son un video ni un audio que Subtitular reconozca (" + string.Join(", ", MediaInfo.Extensions) + ").", + MediaInfo.Select(["a.txt", "b.doc"]).Warning); + } + finally + { + Directory.Delete(dir, true); + } + } +} diff --git a/tests/Subtitular.Tests/PreferencesTests.cs b/tests/Subtitular.Tests/PreferencesTests.cs index cd844d8..32161fe 100644 --- a/tests/Subtitular.Tests/PreferencesTests.cs +++ b/tests/Subtitular.Tests/PreferencesTests.cs @@ -15,6 +15,7 @@ public void Preferences_roundtrip_keep_missing_folders_and_survive_broken_files( Assert.Equal(new Preferences(), defaults); Assert.Equal("auto", defaults.Language); Assert.True(defaults.OpenFolder); + Assert.False(defaults.SaveWithoutReview); Assert.True(defaults.CheckUpdates); Assert.Null(defaults.LastUpdateCheck); Assert.Null(defaults.SkippedVersion); @@ -23,10 +24,12 @@ public void Preferences_roundtrip_keep_missing_folders_and_survive_broken_files( Preferences chosen = new() { LastInputDir = dir, OutputDir = dir, Language = "es", Format = "vtt", TranslateTo = "en", Speak = true, OpenFolder = false, - CheckUpdates = false, LastUpdateCheck = new DateTimeOffset(2026, 11, 1, 9, 30, 0, TimeSpan.FromHours(-5)), SkippedVersion = "1.1.0", + SaveWithoutReview = true, CheckUpdates = false, LastUpdateCheck = new DateTimeOffset(2026, 11, 1, 9, 30, 0, TimeSpan.FromHours(-5)), SkippedVersion = "1.1.0", }; chosen.Save(path); Assert.Equal(chosen, Preferences.Load(path)); + // Escritura atómica: se escribe un .tmp y se renombra encima; no queda rastro. + Assert.False(File.Exists(path + ".tmp")); // 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"); @@ -45,6 +48,7 @@ public void Preferences_roundtrip_keep_missing_folders_and_survive_broken_files( // Un archivo viejo sin las casillas nuevas las deja activadas, y el aviso de versión toca al arrancar. File.WriteAllText(path, """{"language":"en"}"""); Assert.True(Preferences.Load(path).OpenFolder); + Assert.False(Preferences.Load(path).SaveWithoutReview); Assert.True(Preferences.Load(path).CheckUpdates); Assert.Null(Preferences.Load(path).LastUpdateCheck); Assert.Equal("en", Preferences.Load(path).Language); @@ -54,4 +58,30 @@ public void Preferences_roundtrip_keep_missing_folders_and_survive_broken_files( Directory.Delete(dir, true); } } + + [Fact] + public void Saving_where_it_cannot_write_keeps_the_old_file_and_does_not_throw() + { + string dir = Path.Combine(Path.GetTempPath(), "subtitular-prefs-" + Guid.NewGuid().ToString("N")); + try + { + // La ruta es una carpeta: no se puede escribir ahí; el guardado calla y no deja un .tmp. + Directory.CreateDirectory(Path.Combine(dir, "preferencias.json")); + new Preferences { Language = "es" }.Save(Path.Combine(dir, "preferencias.json")); + Assert.False(File.Exists(Path.Combine(dir, "preferencias.json.tmp"))); + Assert.Equal(new Preferences(), Preferences.Load(Path.Combine(dir, "preferencias.json"))); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void One_file_is_reviewed_unless_told_otherwise_and_queues_never_are() + { + Assert.True(SubtitleJob.ReviewsBeforeSaving(1, saveWithoutReview: false)); + Assert.False(SubtitleJob.ReviewsBeforeSaving(1, saveWithoutReview: true)); + Assert.False(SubtitleJob.ReviewsBeforeSaving(3, saveWithoutReview: false)); + } } diff --git a/tests/Subtitular.Tests/SpeechTests.cs b/tests/Subtitular.Tests/SpeechTests.cs index 151d0a9..a62d5d2 100644 --- a/tests/Subtitular.Tests/SpeechTests.cs +++ b/tests/Subtitular.Tests/SpeechTests.cs @@ -45,6 +45,56 @@ public void Wav_is_16_bit_pcm_mono() } } + [Fact] + public void Same_speech_ignores_cue_splits_and_spacing_but_not_words_or_language() + { + Transcript spoken = new("es", "cpu", [new TranscriptSegment(0, 4, "Hola a todos. Segundo bloque."), new TranscriptSegment(4, 5, " ")]); + // La vista previa parte y recorta los bloques: mismas palabras, misma voz. + Transcript reviewed = new("es", "cpu", [new TranscriptSegment(0, 2, "Hola a todos."), new TranscriptSegment(2, 4, " Segundo bloque. ")]); + Assert.True(SpeechSynthesizer.SameSpeech(spoken, reviewed)); + Assert.True(SpeechSynthesizer.SameSpeech(spoken, reviewed with { Language = "ES" })); + Assert.False(SpeechSynthesizer.SameSpeech(spoken, reviewed with { Language = "en" })); + Assert.False(SpeechSynthesizer.SameSpeech(spoken, new Transcript("es", "cpu", [new TranscriptSegment(0, 2, "Hola a todas."), reviewed.Segments[1]]))); + Assert.False(SpeechSynthesizer.SameSpeech(spoken, new Transcript("es", "cpu", [reviewed.Segments[0]]))); + } + + [Fact] + public async Task Save_keeps_the_wav_when_the_text_did_not_change_and_regenerates_otherwise() + { + string dir = Directory.CreateTempSubdirectory("subtitular-voz-").FullName; + try + { + Transcript spoken = new("es", "cpu", [new TranscriptSegment(0, 2, "Hola a todos.")]); + // Con un módulo de voz que no existe, generar la voz falla: si Guardar devuelve el WAV, es que no lo tocó. + JobOptions options = new(Path.Combine(dir, "clip.mp4")) { Speak = true, VoiceModule = Path.Combine(dir, "no-hay-modulo") }; + string wav = Path.Combine(dir, "clip.voz.wav"); + byte[] previous = [1, 2, 3]; + File.WriteAllBytes(wav, previous); + List reports = []; + + (string subtitles, string? audio) = await SubtitleJob.SaveAsync(spoken, options, new SyncProgress(reports.Add), CancellationToken.None, spoken); + Assert.Equal(Path.Combine(dir, "clip.srt"), subtitles); + Assert.Equal(wav, audio); + Assert.Equal(previous, File.ReadAllBytes(wav)); + Assert.Contains("Hola a todos.", File.ReadAllText(subtitles), StringComparison.Ordinal); + Assert.Contains(reports, r => r is { Stage: JobStage.Speak, StageFraction: 1 }); + + // Texto corregido: hay que volver a leerlo (y aquí el módulo no está). + Transcript edited = new("es", "cpu", [new TranscriptSegment(0, 2, "Hola a todas.")]); + await Assert.ThrowsAsync(() => SubtitleJob.SaveAsync(edited, options, null, CancellationToken.None, spoken)); + // Sin WAV previo, aunque el texto sea el mismo, se genera. + File.Delete(wav); + await Assert.ThrowsAsync(() => SubtitleJob.SaveAsync(spoken, options, null, CancellationToken.None, spoken)); + // Sin saber de qué texto era el WAV (la vista previa de un solo archivo), se genera siempre. + File.WriteAllBytes(wav, previous); + await Assert.ThrowsAsync(() => SubtitleJob.SaveAsync(spoken, options, null, CancellationToken.None)); + } + finally + { + Directory.Delete(dir, true); + } + } + [Fact] public void Voice_language_maps_whisper_codes_and_rejects_the_rest() {