diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ede2a4..4be3f69 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Historial de cambios de Subtitular
+## [Sin publicar]
+
+### Añadido
+- **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».
+
+### 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`).
+
+### Corregido
+- 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
### Añadido
diff --git a/README.md b/README.md
index db8bcf7..539c288 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ Subtítulos automáticos para Windows, en tu PC y sin conexión. Le das un video
## Qué hay
-- **`Subtitular.exe`** (ventana): suelta un video o audio, elige idioma, formato (SRT/VTT), carpeta, «Traducir a…» y «Leer en voz alta»; barra de progreso por etapa, Cancelar, vista previa editable bloque a bloque y Guardar. Los errores salen en español dentro de la ventana.
+- **`Subtitular.exe`** (ventana): suelta un video o audio (o varios: se procesan en cola y cada uno se guarda al terminar), elige idioma, formato (SRT/VTT), carpeta, «Traducir a…» y «Leer en voz alta»; barra de progreso por etapa y por archivo, Cancelar, vista previa editable bloque a bloque y Guardar. Recuerda lo último elegido, abre la carpeta del resultado al terminar y responde a `Ctrl+O` y `Ctrl+Entrar`. Los errores salen en español dentro de la ventana.
- **`Subtitular.Cli.exe`**: `Subtitular.Cli video.mp4 [--idioma es] [--formato vtt] [--traducir en] [--voz] [--salida carpeta]`. Deja `video.srt` (o `video.en.srt`) y, con `--voz`, `video.wav` con cada frase en su tiempo.
- Toda la lógica vive en `Subtitular.Core`; la ventana y la CLI solo la llaman. Cifras en `docs/informe-fase-1.md` y `docs/informe-fase-2.md`.
diff --git a/build/catalogo-entrada.ps1 b/build/catalogo-entrada.ps1
index a90256c..17886ef 100644
--- a/build/catalogo-entrada.ps1
+++ b/build/catalogo-entrada.ps1
@@ -40,7 +40,18 @@ if (-not (Test-Path -LiteralPath $Zip)) {
throw "No se encontró el archivo zip de distribución: $Zip (corre antes build\distribuir.ps1)."
}
-$sha256 = (Get-FileHash -LiteralPath $Zip -Algorithm SHA256).Hash.ToLowerInvariant()
+# 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
+# desde pwsh o desde el host de pruebas puede heredar otro PSModulePath (la CI de main fallaba por eso).
+$hasher = [System.Security.Cryptography.SHA256]::Create()
+$flujo = [System.IO.File]::OpenRead($Zip)
+try {
+ $sha256 = [System.BitConverter]::ToString($hasher.ComputeHash($flujo)).Replace('-', '').ToLowerInvariant()
+}
+finally {
+ $flujo.Dispose()
+ $hasher.Dispose()
+}
$size = (Get-Item -LiteralPath $Zip).Length
$url = "https://github.com/Dathinel/subtitular/releases/download/v$Version/Subtitular-$Version-win-x64.zip"
diff --git a/docs/pruebas-a-mano.md b/docs/pruebas-a-mano.md
index 94ea0aa..b6d775e 100644
--- a/docs/pruebas-a-mano.md
+++ b/docs/pruebas-a-mano.md
@@ -24,3 +24,13 @@ Lo que solo se juzga a ojo. Sin captura: la app no tiene `--captura`.
5. **Ventana.** Mueve y redimensiona la ventana, ciérrala y ábrela: vuelve al mismo sitio y tamaño (`%LOCALAPPDATA%\Dathinel\Subtitular\ventana.json`). No se deja bajar de 800×700. Si la cierras maximizada, se queda el último tamaño normal.
6. **Acerca de.** Arriba a la derecha: versión, licencia y el crédito de Rodaje; los dos enlaces abren GitHub en el navegador.
7. **Ritmo.** ¿Las tres tarjetas (archivo, ajustes, subtítulos) con sombra suave se ven ordenadas o recargadas? Con Tab, ¿se ve el foco en cada control?
+
+## Calidad de vida (1.0.1)
+
+Lo que la CI no puede comprobar (la lógica tiene pruebas en `BatchTests` y `PreferencesTests`).
+
+1. **Recordar.** Elige un archivo de una carpeta nueva, pon «Inglés», VTT, «Traducir a… Francés», marca la voz y una carpeta de salida; cierra y abre: todo debe seguir igual, y «Elegir…» debe abrirse en la carpeta del último archivo (`%LOCALAPPDATA%\Dathinel\Subtitular\preferencias.json`). Borra la carpeta de salida y vuelve a abrir: el campo queda vacío (junto al video).
+2. **Varios archivos.** Arrastra tres videos a la vez (o elígelos con Ctrl en el diálogo): la cabecera dice «3 archivos en cola» y debajo sus nombres. Generar: la barra dice «Archivo 1 de 3 · nombre · Transcribiendo · 45 %…», late indeterminada al empezar cada archivo, y al final «3 archivos listos» con la vista previa del último; en la carpeta deben estar los tres `.srt`. Mezcla un `.txt` en lo que sueltas: barra roja con su nombre y los videos siguen cargados. Renombra el segundo video antes de generar: «2 archivos listos, 1 con error: …» y los otros dos guardados.
+3. **Cancelar en cola.** Con tres archivos, cancela durante el segundo: «Operación cancelada.» en menos de un segundo, el primero ya guardado, y en el Administrador de tareas no queda ningún `ffmpeg.exe` ni `Rodaje.Module.*.exe`.
+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.
diff --git a/src/Subtitular.App/FileDialogs.cs b/src/Subtitular.App/FileDialogs.cs
index 525f4b1..32c50c3 100644
--- a/src/Subtitular.App/FileDialogs.cs
+++ b/src/Subtitular.App/FileDialogs.cs
@@ -1,4 +1,5 @@
// Copiado de Rastrear (src/Rastrear.App/FileDialogs.cs), sin SaveSingle: candidato a biblioteca compartida (Herramientas-IA\PLAN.md, «Cómo se comparte el código con Rodaje»).
+// Añadido aquí: varios archivos a la vez (OpenMany) y carpeta inicial (la última elegida).
using System.Runtime.InteropServices;
using TerraFX.Interop.Windows;
using static TerraFX.Interop.Windows.Windows;
@@ -14,6 +15,7 @@ internal static unsafe class FileDialogs
private const uint FosNoChangeDir = 0x8;
private const uint FosPickFolders = 0x20;
private const uint FosForceFileSystem = 0x40;
+ private const uint FosAllowMultiSelect = 0x200;
private const uint FosPathMustExist = 0x800;
private const uint FosFileMustExist = 0x1000;
@@ -23,45 +25,72 @@ 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)
+ 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)
{
IFileOpenDialog* dialog = null;
- IShellItem* item = null;
+ IShellItemArray* items = null;
try
{
if (CoCreateInstance(__uuidof(), null, (uint)CLSCTX.CLSCTX_INPROC_SERVER,
__uuidof(), (void**)&dialog).FAILED)
{
- return null;
+ return [];
}
uint options;
dialog->GetOptions(&options);
options |= FosForceFileSystem | FosFileMustExist | FosPathMustExist | FosNoChangeDir;
+ if (multi)
+ {
+ options |= FosAllowMultiSelect;
+ }
dialog->SetOptions(options);
Configure((IFileDialog*)dialog, title, filters);
+ SetFolder((IFileDialog*)dialog, initialDir);
if (dialog->Show((HWND)owner).FAILED)
{
- return null;
+ return [];
}
- if (dialog->GetResult(&item).FAILED)
+ if (dialog->GetResults(&items).FAILED || items == null)
{
- return null;
+ return [];
}
- return PathOf(item);
+ uint count;
+ items->GetCount(&count);
+ List paths = new((int)count);
+ for (uint i = 0; i < count; i++)
+ {
+ IShellItem* item = null;
+ try
+ {
+ if (items->GetItemAt(i, &item).SUCCEEDED && PathOf(item) is { } path)
+ {
+ paths.Add(path);
+ }
+ }
+ finally
+ {
+ if (item != null) item->Release();
+ }
+ }
+ return paths;
}
finally
{
- if (item != null) item->Release();
+ if (items != null) items->Release();
if (dialog != null) dialog->Release();
}
}
- public static string? ChooseFolder(nint owner, string title)
+ public static string? ChooseFolder(nint owner, string title, string? initialDir = null)
{
IFileOpenDialog* dialog = null;
IShellItem* item = null;
@@ -82,6 +111,7 @@ public static readonly (string Name, string Spec) AllFilter =
{
dialog->SetTitle(pTitle);
}
+ SetFolder((IFileDialog*)dialog, initialDir);
if (dialog->Show((HWND)owner).FAILED)
{
@@ -102,6 +132,30 @@ public static readonly (string Name, string Spec) AllFilter =
}
}
+ /// Carpeta por la que empieza el diálogo; si no existe o falla, Windows pone la de siempre.
+ private static void SetFolder(IFileDialog* dialog, string? dir)
+ {
+ if (string.IsNullOrWhiteSpace(dir) || !Directory.Exists(dir))
+ {
+ return;
+ }
+ IShellItem* folder = null;
+ try
+ {
+ fixed (char* pDir = dir)
+ {
+ if (SHCreateItemFromParsingName(pDir, null, __uuidof(), (void**)&folder).SUCCEEDED && folder != null)
+ {
+ dialog->SetFolder(folder);
+ }
+ }
+ }
+ finally
+ {
+ if (folder != null) folder->Release();
+ }
+ }
+
private static void Configure(IFileDialog* dialog, string title, (string Name, string Spec)[] filters)
{
fixed (char* pTitle = title)
diff --git a/src/Subtitular.App/MainWindow.xaml b/src/Subtitular.App/MainWindow.xaml
index 39af858..bded90b 100644
--- a/src/Subtitular.App/MainWindow.xaml
+++ b/src/Subtitular.App/MainWindow.xaml
@@ -73,10 +73,14 @@
-
+
-
-
+
+
@@ -98,10 +102,12 @@
+
+
@@ -114,7 +120,12 @@
-
+
@@ -168,7 +179,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, cada uno se guarda al terminar." />
diff --git a/src/Subtitular.App/MainWindow.xaml.cs b/src/Subtitular.App/MainWindow.xaml.cs
index 77dd73e..4d395cb 100644
--- a/src/Subtitular.App/MainWindow.xaml.cs
+++ b/src/Subtitular.App/MainWindow.xaml.cs
@@ -35,9 +35,13 @@ private static readonly (string Id, string Name)[] AudioLanguages =
];
private readonly ObservableCollection _cues = [];
- private string? _file;
+ /// La cola: uno o varios archivos con los mismos ajustes.
+ private readonly List _files = [];
+ /// El archivo al que pertenece la vista previa (con varios, el último que salió bien).
+ private string? _previewFile;
private Transcript? _transcript;
private CancellationTokenSource? _cts;
+ private Preferences _prefs;
public MainWindow()
{
@@ -63,6 +67,7 @@ public MainWindow()
{
new WindowPlacement(AppWindow.Position.X, AppWindow.Position.Y, AppWindow.Size.Width, AppWindow.Size.Height).Save(WindowPlacement.DefaultPath);
}
+ CurrentPreferences().Save(Preferences.DefaultPath);
};
VersionText.Text = "Versión " + (typeof(App).Assembly.GetName().Version?.ToString(3) ?? "?") + " · Copyright (C) 2026 Dathinel";
CueList.ItemsSource = _cues;
@@ -71,13 +76,19 @@ public MainWindow()
{
LanguageBox.Items.Add(new ComboBoxItem { Content = name, Tag = id });
}
- LanguageBox.SelectedIndex = 0;
TranslateBox.Items.Add(new ComboBoxItem { Content = "No traducir", Tag = "" });
foreach ((string id, string name) in SubtitleTranslator.Languages)
{
TranslateBox.Items.Add(new ComboBoxItem { Content = name, Tag = id });
}
- TranslateBox.SelectedIndex = 0;
+ // Lo último elegido: idioma, formato, traducción, voz, carpeta de salida y si se abre al terminar.
+ _prefs = Preferences.Load(Preferences.DefaultPath);
+ SelectTag(LanguageBox, _prefs.Language);
+ SelectTag(FormatBox, _prefs.Format);
+ SelectTag(TranslateBox, _prefs.TranslateTo ?? "");
+ SpeakBox.IsChecked = _prefs.Speak;
+ OpenFolderBox.IsChecked = _prefs.OpenFolder;
+ OutputDirBox.Text = _prefs.OutputDir ?? "";
CompositionTarget.Rendering += OnFirstFrame;
}
@@ -98,31 +109,47 @@ private void Show(InfoBarSeverity severity, string message)
private static string Tag(ComboBox box) => (box.SelectedItem as ComboBoxItem)?.Tag as string ?? "";
+ /// Elige el elemento con esa etiqueta; si no está (idioma retirado), el primero.
+ private static void SelectTag(ComboBox box, string tag)
+ {
+ int index = box.Items.ToList().FindIndex(i => (i as ComboBoxItem)?.Tag as string == tag);
+ box.SelectedIndex = index >= 0 ? index : 0;
+ }
+
+ private Preferences CurrentPreferences() => _prefs with
+ {
+ OutputDir = string.IsNullOrWhiteSpace(OutputDirBox.Text) ? null : OutputDirBox.Text,
+ Language = Tag(LanguageBox),
+ Format = Tag(FormatBox),
+ TranslateTo = Tag(TranslateBox) is { Length: > 0 } t ? t : null,
+ Speak = SpeakBox.IsChecked == true,
+ OpenFolder = OpenFolderBox.IsChecked == true,
+ };
+
private void OnDragOver(object sender, DragEventArgs e) =>
e.AcceptedOperation = e.DataView.Contains(StandardDataFormats.StorageItems) ? DataPackageOperation.Copy : DataPackageOperation.None;
private async void OnDrop(object sender, DragEventArgs e) => await GuardedAsync(async () =>
{
IReadOnlyList items = await e.DataView.GetStorageItemsAsync();
- if (items.FirstOrDefault() is IStorageFile file)
- {
- await LoadFileAsync(file.Path);
- }
+ await LoadFilesAsync([.. items.OfType().Select(f => f.Path)]);
});
private nint Hwnd => WinRT.Interop.WindowNative.GetWindowHandle(this);
private async void OnChoose(object sender, RoutedEventArgs e) => await GuardedAsync(async () =>
{
- if (FileDialogs.OpenSingle(Hwnd, "Elegir video o audio", FileDialogs.MediaFilter, FileDialogs.AllFilter) is { } path)
+ IReadOnlyList paths = FileDialogs.OpenMany(Hwnd, "Elegir video o audio", _prefs.LastInputDir, true, FileDialogs.MediaFilter, FileDialogs.AllFilter);
+ if (paths.Count > 0)
{
- await LoadFileAsync(path);
+ await LoadFilesAsync(paths);
}
});
private void OnChooseFolder(object sender, RoutedEventArgs e)
{
- if (FileDialogs.ChooseFolder(Hwnd, "Carpeta de salida") is { } folder)
+ string? current = string.IsNullOrWhiteSpace(OutputDirBox.Text) ? _prefs.LastInputDir : OutputDirBox.Text;
+ if (FileDialogs.ChooseFolder(Hwnd, "Carpeta de salida", current) is { } folder)
{
OutputDirBox.Text = folder;
}
@@ -141,22 +168,50 @@ private async Task GuardedAsync(Func action)
}
}
- private async Task LoadFileAsync(string path)
+ /// Carga la cola: uno o varios archivos. Los que no son video ni audio se avisan y se dejan fuera.
+ private async Task LoadFilesAsync(IReadOnlyList paths)
{
+ if (paths.Count == 0)
+ {
+ return;
+ }
+ if (_cts is not null)
+ {
+ ShowError("Hay un trabajo en marcha: espera a que termine o pulsa Cancelar antes de cambiar los archivos.");
+ return;
+ }
Notice.IsOpen = false;
- if (!MediaInfo.Extensions.Contains(Path.GetExtension(path).ToLowerInvariant()))
+ 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)
+ {
+ 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)}).");
+ }
+ if (valid.Count == 0)
{
- ShowError($"«{Path.GetFileName(path)}» no es un video ni un audio que Subtitular reconozca ({string.Join(", ", MediaInfo.Extensions)}).");
return;
}
- _file = path;
+ _files.Clear();
+ _files.AddRange(valid);
+ _prefs = _prefs with { LastInputDir = Path.GetDirectoryName(Path.GetFullPath(valid[0])) };
+ _previewFile = null;
_transcript = null;
_cues.Clear();
SaveButton.IsEnabled = false;
- FileName.Text = Path.GetFileName(path);
- FileInfo.Text = path;
RunButton.IsEnabled = true;
+ if (valid.Count > 1)
+ {
+ FileName.Text = $"{valid.Count} archivos en cola";
+ IEnumerable shown = valid.Take(5).Select(p => Path.GetFileName(p));
+ FileInfo.Text = string.Join(", ", shown) + (valid.Count > 5 ? $" y {valid.Count - 5} más" : "") + " · cada uno se guarda al terminar";
+ return;
+ }
+
+ string path = valid[0];
+ FileName.Text = Path.GetFileName(path);
+ FileInfo.Text = path;
List info = [];
try
{
@@ -174,7 +229,7 @@ private async Task LoadFileAsync(string path)
FileInfo.Text = string.Join(" · ", info);
}
- private JobOptions Options() => new(_file!)
+ private JobOptions Options(string file) => new(file)
{
Language = Tag(LanguageBox),
Format = Tag(FormatBox),
@@ -189,43 +244,96 @@ private async Task LoadFileAsync(string path)
private async void OnRun(object sender, RoutedEventArgs e)
{
- if (_file is null)
+ if (_files.Count == 0 || _cts is not null)
{
return;
}
_cues.Clear();
_transcript = null;
+ _previewFile = null;
SaveButton.IsEnabled = false;
- Transcript? transcript = await BusyAsync((options, progress, token) => SubtitleJob.RunAsync(options, progress, token));
- if (transcript is null)
+ if (_files.Count == 1)
+ {
+ string file = _files[0];
+ Transcript? transcript = await BusyAsync((progress, token) => SubtitleJob.RunAsync(Options(file), Single(progress, file), token));
+ if (transcript is null)
+ {
+ return;
+ }
+ ShowPreview(file, transcript);
+ 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.
+ List jobs = [.. _files.Select(Options)];
+ IReadOnlyList? results = await BusyAsync((progress, token) => JobBatch.RunAsync(jobs, progress, token));
+ if (results is null)
{
return;
}
+ if (results.LastOrDefault(r => r.Ok) is { Transcript: { } last } ok)
+ {
+ ShowPreview(ok.Options.InputFile, last);
+ }
+ 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)
+ {
+ _previewFile = file;
_transcript = transcript;
foreach (SubtitleCue cue in SubtitlePreview.Cues(transcript))
{
_cues.Add(new CueItem(cue));
}
SaveButton.IsEnabled = _cues.Count > 0;
- Show(InfoBarSeverity.Informational, $"{_cues.Count} bloques en «{transcript.Language}». Revisa el texto y pulsa Guardar.");
}
private async void OnSave(object sender, RoutedEventArgs e)
{
- if (_transcript is null)
+ if (_transcript is null || _previewFile is null || _cts is not null)
{
return;
}
+ string file = _previewFile;
Transcript edited = SubtitlePreview.ToTranscript(_cues.Select(c => (c.Start, c.End, c.Text)), _transcript);
- (string Subtitles, string? Audio)? saved = await BusyAsync((options, progress, token) => SubtitleJob.SaveAsync(edited, options, progress, token));
+ (string Subtitles, string? Audio)? saved = await BusyAsync((progress, token) => SubtitleJob.SaveAsync(edited, Options(file), Single(progress, file), token));
if (saved is { } s)
{
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.
+ 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)
+ {
+ }
+ }
+
+ /// 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);
+ }
+
/// 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)
+ private async Task BusyAsync(Func, CancellationToken, Task> work)
{
Notice.IsOpen = false;
_cts = new CancellationTokenSource();
@@ -237,22 +345,28 @@ private async void OnSave(object sender, RoutedEventArgs e)
// Hasta el primer aviso del módulo (cargar el modelo) la barra late indeterminada y el reloj corre.
Progress.IsIndeterminate = true;
Stopwatch clock = Stopwatch.StartNew();
- JobProgress? last = null;
+ BatchProgress? last = null;
DispatcherQueueTimer timer = DispatcherQueue.CreateTimer();
timer.Interval = TimeSpan.FromMilliseconds(500);
timer.Tick += (_, _) => StageText.Text = last?.Status(clock.Elapsed) ?? "Preparando · " + JobProgress.FormatClock(clock.Elapsed);
timer.Start();
- Progress progress = new(p =>
+ Progress progress = new(p =>
{
last = p;
- Progress.IsIndeterminate = false;
StageText.Text = p.Status(clock.Elapsed);
+ if (p.Progress is null)
+ {
+ // Empieza otro archivo de la cola: vuelve a latir hasta que su módulo avise.
+ Progress.IsIndeterminate = true;
+ return;
+ }
+ Progress.IsIndeterminate = false;
BarAnim.To = p.Overall * 100;
BarStory.Begin();
});
try
{
- T result = await work(Options(), progress, _cts.Token);
+ T result = await work(progress, _cts.Token);
DoneStory.Begin();
return result;
}
@@ -270,7 +384,7 @@ private async void OnSave(object sender, RoutedEventArgs e)
BarStory.Stop();
Progress.IsIndeterminate = false;
Progress.Value = 0;
- RunButton.IsEnabled = true;
+ RunButton.IsEnabled = _files.Count > 0;
SaveButton.IsEnabled = _transcript is not null;
CancelButton.IsEnabled = false;
}
diff --git a/src/Subtitular.Core/JobBatch.cs b/src/Subtitular.Core/JobBatch.cs
new file mode 100644
index 0000000..a001425
--- /dev/null
+++ b/src/Subtitular.Core/JobBatch.cs
@@ -0,0 +1,116 @@
+using System.Runtime.Versioning;
+
+namespace Subtitular.Core;
+
+/// Avance de la cola: qué archivo va (desde 0), cuántos hay y, si ya avisó, la etapa de ese archivo.
+public readonly record struct BatchProgress(int Index, int Count, string InputFile, JobProgress? Progress)
+{
+ /// Fracción del total de la cola: cada archivo pesa igual.
+ public double Overall => Count <= 0 ? 0 : (Index + (Progress?.Overall ?? 0)) / Count;
+
+ /// Con varios archivos, «Archivo 2 de 5 · clip.mp4 · Transcribiendo · 45 % · 0:12 · quedan ~0:15»; con uno, solo la etapa.
+ public string Status(TimeSpan elapsed)
+ {
+ string stage = Progress is { } p ? p.Status(elapsed, Overall) : "Preparando · " + JobProgress.FormatClock(elapsed);
+ return Count > 1 ? $"Archivo {Index + 1} de {Count} · {Path.GetFileName(InputFile)} · {stage}" : stage;
+ }
+}
+
+/// 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)
+{
+ public bool Ok => Error is null;
+}
+
+/// Varios archivos uno tras otro, con los mismos ajustes; cada uno se guarda al terminar.
+[SupportedOSPlatform("windows")]
+public static class JobBatch
+{
+ /// Lo que se hace con un archivo: transcribir (y traducir) y guardar. Las pruebas ponen uno de mentira.
+ public delegate Task<(Transcript Transcript, string Subtitles, string? Audio)> Worker(JobOptions options, IProgress progress, CancellationToken cancellationToken);
+
+ /// El de verdad: y .
+ public static async Task<(Transcript Transcript, string Subtitles, string? Audio)> RunAndSaveAsync(JobOptions options, IProgress progress, CancellationToken cancellationToken)
+ {
+ Transcript transcript = await SubtitleJob.RunAsync(options, progress, cancellationToken).ConfigureAwait(false);
+ (string subtitles, string? audio) = await SubtitleJob.SaveAsync(transcript, options, progress, cancellationToken).ConfigureAwait(false);
+ return (transcript, subtitles, audio);
+ }
+
+ ///
+ /// 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 .
+ ///
+ public static async Task> RunAsync(IReadOnlyList jobs, IProgress? progress = null,
+ CancellationToken cancellationToken = default, Worker? worker = null)
+ {
+ ArgumentNullException.ThrowIfNull(jobs);
+ worker ??= RunAndSaveAsync;
+ List results = new(jobs.Count);
+ for (int i = 0; i < jobs.Count; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ JobOptions options = jobs[i];
+ 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)));
+ try
+ {
+ (Transcript transcript, string subtitles, string? audio) = await worker(options, stage, cancellationToken).ConfigureAwait(false);
+ results.Add(new BatchResult(options, transcript, subtitles, audio, null));
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ results.Add(new BatchResult(options, null, null, null, SubtitleJob.Describe(ex) ?? "Error inesperado: " + ex.Message));
+ }
+ }
+ return results;
+ }
+
+ private sealed class Relay(Action 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».
+ 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)}";
+ }
+}
+
+/// 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/Model.cs b/src/Subtitular.Core/Model.cs
index 6f394dd..d1e20e4 100644
--- a/src/Subtitular.Core/Model.cs
+++ b/src/Subtitular.Core/Model.cs
@@ -50,6 +50,7 @@ public sealed record SubtitleParameters(
[JsonSerializable(typeof(TranscriptWord))]
[JsonSerializable(typeof(SubtitleParameters))]
[JsonSerializable(typeof(WindowPlacement))]
+[JsonSerializable(typeof(Preferences))]
public sealed partial class SubtitularJsonContext : JsonSerializerContext;
/// Un subtítulo para «traducir»: el id es el índice del segmento.
diff --git a/src/Subtitular.Core/Preferences.cs b/src/Subtitular.Core/Preferences.cs
new file mode 100644
index 0000000..c83d3ae
--- /dev/null
+++ b/src/Subtitular.Core/Preferences.cs
@@ -0,0 +1,59 @@
+using System.Text.Json;
+
+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. Constructor primario con valores por omisión: una propiedad
+/// que falte en un archivo viejo toma el suyo al deserializar.
+///
+public sealed record Preferences(
+ string? LastInputDir = null,
+ string? OutputDir = null,
+ string Language = "auto",
+ string Format = "srt",
+ string? TranslateTo = null,
+ bool Speak = false,
+ bool OpenFolder = true)
+{
+ 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.
+ public static Preferences Load(string path)
+ {
+ Preferences p;
+ try
+ {
+ p = JsonSerializer.Deserialize(File.ReadAllBytes(path), SubtitularJsonContext.Default.Preferences) ?? new Preferences();
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
+ {
+ return new Preferences();
+ }
+ return p with
+ {
+ LastInputDir = ExistingDir(p.LastInputDir),
+ OutputDir = ExistingDir(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,
+ };
+ }
+
+ 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)
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllBytes(path, JsonSerializer.SerializeToUtf8Bytes(this, SubtitularJsonContext.Default.Preferences));
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ }
+ }
+}
diff --git a/src/Subtitular.Core/SubtitleJob.cs b/src/Subtitular.Core/SubtitleJob.cs
index db6bc17..0e1d19e 100644
--- a/src/Subtitular.Core/SubtitleJob.cs
+++ b/src/Subtitular.Core/SubtitleJob.cs
@@ -37,10 +37,13 @@ public readonly record struct JobProgress(JobStage Stage, double StageFraction,
};
/// Etapa, porcentaje y tiempo transcurrido; el que falta se estima por el total y solo con base (≥ 5 %).
- public string Status(TimeSpan elapsed)
+ public string Status(TimeSpan elapsed) => Status(elapsed, Overall);
+
+ /// Igual, pero el que falta se estima sobre otro total (el de una cola de varios archivos).
+ public string Status(TimeSpan elapsed, double overall)
{
string s = $"{Label} · {(int)(StageFraction * 100)} % · {FormatClock(elapsed)}";
- return Overall >= 0.05 ? s + $" · quedan ~{FormatClock(elapsed * (1 / Overall - 1))}" : s;
+ return overall >= 0.05 ? s + $" · quedan ~{FormatClock(elapsed * (1 / overall - 1))}" : s;
}
/// m:ss, y h:mm:ss a partir de una hora.
diff --git a/tests/Subtitular.Tests/BatchTests.cs b/tests/Subtitular.Tests/BatchTests.cs
new file mode 100644
index 0000000..ec45654
--- /dev/null
+++ b/tests/Subtitular.Tests/BatchTests.cs
@@ -0,0 +1,93 @@
+using Subtitular.Core;
+
+namespace Subtitular.Tests;
+
+public sealed class BatchTests
+{
+ private static readonly Transcript Empty = new("es", "cpu", []);
+
+ private static JobOptions Job(string name) => new(Path.Combine(Path.GetTempPath(), name));
+
+ [Fact]
+ public async Task Batch_runs_files_in_order_and_reports_progress_per_file()
+ {
+ List seen = [];
+ List reports = [];
+ IReadOnlyList results = await JobBatch.RunAsync([Job("a.mp4"), Job("b.mkv")], new Collector(reports), CancellationToken.None, async (o, p, _) =>
+ {
+ seen.Add(o.InputFile);
+ p.Report(JobProgress.Of(SubtitleJob.StagesOf(o), JobStage.Transcribe, 0.5));
+ await Task.Yield();
+ return (Empty, SubtitleJob.OutputPath(o, ".srt"), null);
+ });
+
+ Assert.Equal([Job("a.mp4").InputFile, Job("b.mkv").InputFile], seen);
+ Assert.Equal(2, results.Count);
+ Assert.All(results, r => Assert.True(r.Ok));
+ Assert.Equal(Path.Combine(Path.GetTempPath(), "b.srt"), results[1].Subtitles);
+
+ Assert.Contains(reports, r => r is { Index: 0, Count: 2, Progress: null });
+ BatchProgress half = Assert.Single(reports, r => r is { Index: 1, Progress: { Stage: JobStage.Transcribe } });
+ Assert.Equal((1 + 0.75) / 2, half.Overall, 3);
+ Assert.StartsWith("Archivo 2 de 2 · b.mkv · Transcribiendo · 50 %", half.Status(TimeSpan.FromSeconds(10)), StringComparison.Ordinal);
+ }
+
+ /// Avisos en el mismo hilo, sin el encolado de .
+ private sealed class Collector(List reports) : IProgress
+ {
+ public void Report(BatchProgress value) => reports.Add(value);
+ }
+
+ [Fact]
+ public async Task One_failing_file_does_not_stop_the_rest()
+ {
+ IReadOnlyList results = await JobBatch.RunAsync([Job("a.mp4"), Job("b.mp4"), Job("c.mp4")], null, CancellationToken.None, (o, _, _) =>
+ o.InputFile.EndsWith("b.mp4", StringComparison.Ordinal)
+ ? throw new SubtitularUserException("falta el módulo")
+ : Task.FromResult((Empty, SubtitleJob.OutputPath(o, ".srt"), (string?)null)));
+
+ Assert.Equal([true, false, true], results.Select(r => r.Ok));
+ Assert.Equal("falta el módulo", results[1].Error);
+ Assert.Equal("2 archivos listos, 1 con error: b.mp4", JobBatch.Summary(results));
+ Assert.Equal("1 archivo listo", JobBatch.Summary([results[0]]));
+ }
+
+ [Fact]
+ public async Task Cancel_stops_the_queue_at_the_current_file()
+ {
+ using CancellationTokenSource cts = new();
+ int started = 0;
+ Task> run = JobBatch.RunAsync([Job("a.mp4"), Job("b.mp4")], null, cts.Token, async (_, _, token) =>
+ {
+ started++;
+ cts.Cancel();
+ await Task.Delay(Timeout.Infinite, token);
+ return (Empty, "", null);
+ });
+ await Assert.ThrowsAnyAsync(() => run);
+ Assert.Equal(1, started);
+ }
+
+ [Fact]
+ public void Single_file_status_has_no_queue_prefix_and_starts_as_preparing()
+ {
+ BatchProgress preparing = new(0, 1, "clip.mp4", null);
+ Assert.Equal("Preparando · 0:03", preparing.Status(TimeSpan.FromSeconds(3)));
+ Assert.Equal(0, preparing.Overall);
+ 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 d64a58f..0480e3d 100644
--- a/tests/Subtitular.Tests/CatalogoEntradaTests.cs
+++ b/tests/Subtitular.Tests/CatalogoEntradaTests.cs
@@ -38,6 +38,9 @@ public void Catalogo_entrada_tiene_la_huella_real_del_zip_y_pasa_por_SignedIndex
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");
diff --git a/tests/Subtitular.Tests/PreferencesTests.cs b/tests/Subtitular.Tests/PreferencesTests.cs
new file mode 100644
index 0000000..afd5191
--- /dev/null
+++ b/tests/Subtitular.Tests/PreferencesTests.cs
@@ -0,0 +1,47 @@
+using Subtitular.Core;
+
+namespace Subtitular.Tests;
+
+public sealed class PreferencesTests
+{
+ [Fact]
+ public void Preferences_roundtrip_and_forget_missing_folders_and_broken_files()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), "subtitular-prefs-" + Guid.NewGuid().ToString("N"));
+ string path = Path.Combine(dir, "preferencias.json");
+ try
+ {
+ Preferences defaults = Preferences.Load(path);
+ Assert.Equal(new Preferences(), defaults);
+ Assert.Equal("auto", defaults.Language);
+ Assert.True(defaults.OpenFolder);
+
+ Directory.CreateDirectory(dir);
+ Preferences chosen = new() { LastInputDir = dir, OutputDir = dir, Language = "es", Format = "vtt", TranslateTo = "en", Speak = true, OpenFolder = false };
+ 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.
+ string gone = Path.Combine(dir, "borrada");
+ new Preferences { LastInputDir = gone, OutputDir = gone, Language = " ", Format = "ass", TranslateTo = "" }.Save(path);
+ Preferences loaded = Preferences.Load(path);
+ Assert.Null(loaded.LastInputDir);
+ Assert.Null(loaded.OutputDir);
+ Assert.Equal("auto", loaded.Language);
+ Assert.Equal("srt", loaded.Format);
+ Assert.Null(loaded.TranslateTo);
+
+ File.WriteAllText(path, "{ roto");
+ Assert.Equal(new Preferences(), Preferences.Load(path));
+
+ // Un archivo viejo sin la casilla nueva la deja activada.
+ File.WriteAllText(path, """{"language":"en"}""");
+ Assert.True(Preferences.Load(path).OpenFolder);
+ Assert.Equal("en", Preferences.Load(path).Language);
+ }
+ finally
+ {
+ Directory.Delete(dir, true);
+ }
+ }
+}