Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<versión>», 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
Expand Down
5 changes: 5 additions & 0 deletions build/catalogo-entrada.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
8 changes: 8 additions & 0 deletions docs/pruebas-a-mano.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Version>` en `Directory.Build.props` a `0.9.0` y publica.
Expand Down
6 changes: 4 additions & 2 deletions src/Subtitular.App/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
</Button.KeyboardAccelerators>
</Button>
<StackPanel VerticalAlignment="Center" Spacing="4">
<TextBlock x:Name="FileName" Text="Suelta aquí uno o varios videos o audios" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock x:Name="FileName" Text="Suelta aquí videos o audios, o una carpeta con ellos" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock x:Name="FileInfo" Text="mp4, mkv, mov, mp3, wav y más; o pulsa Elegir… (Ctrl+O)" Foreground="{ThemeResource TextFillColorSecondaryBrush}" TextWrapping="Wrap" />
</StackPanel>
</StackPanel>
Expand Down Expand Up @@ -125,6 +125,8 @@
</Grid>
<!-- Versión nueva: al arrancar (como mucho una consulta a GitHub cada 24 h) o al momento con el enlace. -->
<StackPanel Orientation="Horizontal" Spacing="16">
<CheckBox x:Name="SkipReviewBox" Content="Guardar sin revisar"
ToolTipService.ToolTip="Guarda el archivo al terminar, sin pasar por la vista previa (como la cola de varios)" />
<CheckBox x:Name="UpdatesBox" Content="Avisar cuando haya una versión nueva" IsChecked="True" />
<HyperlinkButton x:Name="CheckUpdatesButton" Content="Buscar actualizaciones" Click="OnCheckUpdates" VerticalAlignment="Center" Padding="4,2"
ToolTipService.ToolTip="Consulta ahora la última versión publicada en GitHub" />
Expand Down Expand Up @@ -199,7 +201,7 @@
</Viewbox>
<TextBlock Text="Aquí saldrán los subtítulos" Style="{StaticResource Seccion}" HorizontalAlignment="Center" Margin="0" />
<TextBlock TextWrapping="Wrap" TextAlignment="Center" Foreground="{ThemeResource TextFillColorSecondaryBrush}"
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." />
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." />
</StackPanel>
</Grid>
</Border>
Expand Down
67 changes: 42 additions & 25 deletions src/Subtitular.App/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ private static readonly (string Id, string Name)[] AudioLanguages =
/// <summary>El archivo al que pertenece la vista previa (con varios, el último que salió bien).</summary>
private string? _previewFile;
private Transcript? _transcript;
/// <summary>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ó.</summary>
private Transcript? _spoken;
private CancellationTokenSource? _cts;
private Preferences _prefs;
/// <summary>La versión nueva que muestra la franja, y el Lanzador si está en el PC.</summary>
Expand Down Expand Up @@ -91,14 +93,15 @@ 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.
LanguageBox.SelectionChanged += (_, _) => SavePreferences();
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();
Expand Down Expand Up @@ -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;

/// <summary>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.</summary>
private async void OnDrop(object sender, DragEventArgs e) => await GuardedAsync(async () =>
{
IReadOnlyList<IStorageItem> items = await e.DataView.GetStorageItemsAsync();
await LoadFilesAsync([.. items.OfType<IStorageFile>().Select(f => f.Path)]);
List<string> 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);
Expand Down Expand Up @@ -275,7 +286,7 @@ private async Task GuardedAsync(Func<Task> action)
}
}

/// <summary>Carga la cola: uno o varios archivos. Los que no son video ni audio se avisan y se dejan fuera.</summary>
/// <summary>Carga la cola: archivos o carpetas (<see cref="MediaInfo.Select"/>). Lo que no es video ni audio se avisa y se deja fuera.</summary>
private async Task LoadFilesAsync(IReadOnlyList<string> paths)
{
if (paths.Count == 0)
Expand All @@ -288,13 +299,12 @@ private async Task LoadFilesAsync(IReadOnlyList<string> paths)
return;
}
Notice.IsOpen = false;
List<string> valid = [.. paths.Where(p => MediaInfo.Extensions.Contains(Path.GetExtension(p).ToLowerInvariant()))];
List<string> 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<string> valid = [.. selection.Media];
if (valid.Count == 0)
{
return;
Expand All @@ -305,6 +315,7 @@ private async Task LoadFilesAsync(IReadOnlyList<string> paths)
SavePreferences();
_previewFile = null;
_transcript = null;
_spoken = null;
_cues.Clear();
SaveButton.IsEnabled = false;
RunButton.IsEnabled = true;
Expand Down Expand Up @@ -359,21 +370,22 @@ 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));
if (transcript is null)
{
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<JobOptions> jobs = [.. _files.Select(Options)];
IReadOnlyList<BatchResult>? results = await BusyAsync((progress, token) => JobBatch.RunAsync(jobs, progress, token));
if (results is null)
Expand All @@ -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)
/// <summary><paramref name="spoken"/>: la transcripción de la que ya quedó el WAV de voz, si lo hay.</summary>
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));
Expand All @@ -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]);
}
}

/// <summary>Abre el Explorador en el resultado si la casilla está marcada; si no se puede, no pasa nada.</summary>
/// <summary>Abre el Explorador en el resultado si la casilla está marcada.</summary>
private void OpenResultFolder(IReadOnlyList<string?> 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);
}
}

Expand Down
41 changes: 41 additions & 0 deletions src/Subtitular.App/ResultFolder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Diagnostics;

namespace Subtitular.App;

/// <summary>Dónde quedó el resultado, para abrirlo en el Explorador al terminar (cosa de la ventana, no de Core).</summary>
internal static class ResultFolder
{
/// <summary>Carpeta común de las salidas; si están en varias, la de la primera. Nula sin salidas.</summary>
public static string? Of(IEnumerable<string?> outputs) =>
outputs.Where(o => !string.IsNullOrWhiteSpace(o)).Select(o => Path.GetDirectoryName(Path.GetFullPath(o!))).FirstOrDefault(d => d is not null);

/// <summary>
/// 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.
/// </summary>
public static (string Exe, string Arguments)? ExplorerCommand(IReadOnlyList<string?> outputs)
{
List<string> 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;
}

/// <summary>Abre el Explorador en el resultado; si no se puede, no pasa nada.</summary>
public static void Open(IReadOnlyList<string?> 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)
{
}
}
}
Loading
Loading