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
12 changes: 5 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,10 @@ Herramienta de IA aparte de Rodaje, con interfaz propia. Repositorio público `D
- **Worktrees** en `.claude\worktrees\` (ignorado). Un commit por tarea y push.
- **Módulos:** `Modules.Find` los busca, en orden, en `SUBTITULAR_MODULOS`, la carpeta del Lanzador (`%LOCALAPPDATA%\Dathinel\Comun\modulos\modulo-*\<versión>`, la más nueva), los de Rodaje (`%LOCALAPPDATA%\Rodaje\complementos\modulos\modulo-*`, `RODAJE_MODULOS`) y `modulos\` junto al exe (`ModulesFindTests`). Las pruebas largas (`RODAJE_PRUEBAS_LARGAS=1`) necesitan los tres módulos con sus modelos y el corpus FLEURS en `RODAJE_ENTORNO`.

## Estado (24-oct)
## Estado (31-oct)

- Fase 0 cerrada: esqueleto, licencia y `src\Subtitular.slnx`.
- Fase 1 cerrada: `Subtitular.Core` (decodificar, `Rodaje.Module.Subtitles`, SRT/VTT) y `Subtitular.Cli`; WER 2,13 % (`docs/informe-fase-1.md`).
- Fase 2 cerrada: ventana WinUI 3 con AOT, traducción y voz en Core y CLI (`docs/informe-fase-2.md`).
- Fase 3 cerrada y verificada (`docs/informe-fase-3.md`): `build\distribuir.ps1` deja en `dist\` el zip AOT autocontenido (26,2 MB), el instalador Inno Setup por usuario (16,8 MB), `catalogo-subtitular.json` (entrada `app`), `notas-version.md` y `SHA256SUMS`; `ci.yml` y `release.yml` (solo `workflow_dispatch`, `--draft`). Pendiente legal: términos del Windows App SDK (`docs/legal.md`).
- Fases 0-3 cerradas y verificadas (`docs/informe-fase-1.md` a `-3.md`): Core, CLI, ventana WinUI 3 con AOT, `build\distribuir.ps1` (zip AOT, instalador Inno Setup, `catalogo-subtitular.json`, `notas-version.md`, `SHA256SUMS`), `ci.yml` y `release.yml` (`workflow_dispatch`, `--draft`). Pendiente legal: términos del Windows App SDK (`docs/legal.md`).
- Submódulo `externo/rodaje` fijado a PR #445 (`0436ad75`, `IndexEntryKind.App`).
- **1.0.0 publicada** el 24-oct: release `v1.0.0` en GitHub (zip, instalador y `SHA256SUMS`), compilación `-warnaserror` sin avisos, 34 pruebas en verde con la larga (WER 2,13 %).
- **Siguiente:** Daniel firma `dist\catalogo-subtitular.json` (URL y huella de la release) en `Dathinel/rodaje-catalogo`; luego 1.0.1 con lo que salga de `docs/pruebas-a-mano.md`.
- **1.0.0 publicada** el 24-oct: release `v1.0.0` en GitHub (zip, instalador y `SHA256SUMS`), `-warnaserror` sin avisos, 34 pruebas en verde con la larga (WER 2,13 %).
- **PR #1 unido en `main`** el 31-oct: cola de archivos, abrir la carpeta al terminar, atajos, preferencias y el arreglo de la CI (`Get-FileHash`). Todo está en «[Sin publicar]» del `CHANGELOG.md`; `Directory.Build.props` sigue en **1.0.0** a propósito: se sube a 1.0.1 y se cierra «[Sin publicar]» en el mismo commit que publique la 1.0.1.
- **Siguiente:** Daniel firma `dist\catalogo-subtitular.json` (URL y huella de la release) en `Dathinel/rodaje-catalogo`; las correcciones de la revisión de bloque (PR `claude/arreglos-revision`) y lo que salga de `docs/pruebas-a-mano.md` van a la 1.0.1.
2 changes: 2 additions & 0 deletions build/catalogo-entrada.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ if (-not $Zip) {
if (-not (Test-Path -LiteralPath $Zip)) {
throw "No se encontró el archivo zip de distribución: $Zip (corre antes build\distribuir.ps1)."
}
# Las clases de .NET resuelven una ruta relativa contra el directorio del proceso, no contra $PWD como Test-Path.
$Zip = (Resolve-Path -LiteralPath $Zip).Path

# Huella con las clases de .NET y no con Get-FileHash: en Windows PowerShell 5.1 es una función de script de
# Microsoft.PowerShell.Utility que solo existe si PSModulePath apunta a $PSHOME\Modules, y un proceso lanzado
Expand Down
3 changes: 0 additions & 3 deletions src/Subtitular.App/FileDialogs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ public static readonly (string Name, string Spec) MediaFilter =
public static readonly (string Name, string Spec) AllFilter =
("Todos los archivos", "*.*");

public static string? OpenSingle(nint owner, string title, params (string Name, string Spec)[] filters) =>
OpenMany(owner, title, null, false, filters).FirstOrDefault();

/// <summary>Uno o varios archivos (Ctrl o Mayús en el diálogo); vacío si se cancela. Empieza en <paramref name="initialDir"/> si existe.</summary>
public static IReadOnlyList<string> OpenMany(nint owner, string title, string? initialDir, bool multi, params (string Name, string Spec)[] filters)
{
Expand Down
28 changes: 20 additions & 8 deletions src/Subtitular.App/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public MainWindow()
{
new WindowPlacement(AppWindow.Position.X, AppWindow.Position.Y, AppWindow.Size.Width, AppWindow.Size.Height).Save(WindowPlacement.DefaultPath);
}
CurrentPreferences().Save(Preferences.DefaultPath);
SavePreferences();
};
VersionText.Text = "Versión " + (typeof(App).Assembly.GetName().Version?.ToString(3) ?? "?") + " · Copyright (C) 2026 Dathinel";
CueList.ItemsSource = _cues;
Expand All @@ -93,6 +93,16 @@ public MainWindow()
OpenFolderBox.IsChecked = _prefs.OpenFolder;
UpdatesBox.IsChecked = _prefs.CheckUpdates;
OutputDirBox.Text = _prefs.OutputDir ?? "";
// Se guardan al cambiar, no solo al cerrar: un apagón o un cierre forzado no las pierde.
LanguageBox.SelectionChanged += (_, _) => SavePreferences();
FormatBox.SelectionChanged += (_, _) => SavePreferences();
TranslateBox.SelectionChanged += (_, _) => SavePreferences();
OutputDirBox.TextChanged += (_, _) => SavePreferences();
foreach (CheckBox box in new[] { SpeakBox, OpenFolderBox })
{
box.Checked += (_, _) => SavePreferences();
box.Unchecked += (_, _) => SavePreferences();
}
CompositionTarget.Rendering += OnFirstFrame;
}

Expand Down Expand Up @@ -210,6 +220,8 @@ private static void SelectTag(ComboBox box, string tag)
box.SelectedIndex = index >= 0 ? index : 0;
}

private void SavePreferences() => CurrentPreferences().Save(Preferences.DefaultPath);

private Preferences CurrentPreferences() => _prefs with
{
OutputDir = string.IsNullOrWhiteSpace(OutputDirBox.Text) ? null : OutputDirBox.Text,
Expand Down Expand Up @@ -290,6 +302,7 @@ private async Task LoadFilesAsync(IReadOnlyList<string> paths)
_files.Clear();
_files.AddRange(valid);
_prefs = _prefs with { LastInputDir = Path.GetDirectoryName(Path.GetFullPath(valid[0])) };
SavePreferences();
_previewFile = null;
_transcript = null;
_cues.Clear();
Expand Down Expand Up @@ -371,6 +384,7 @@ private async void OnRun(object sender, RoutedEventArgs e)
{
ShowPreview(ok.Options.InputFile, last);
}
// Cancelar no tira lo ya guardado: el resumen dice cuántos salieron y en cuál se paró.
Show(results.All(r => r.Ok) ? InfoBarSeverity.Success : InfoBarSeverity.Warning, JobBatch.Summary(results));
OpenResultFolder([.. results.Where(r => r.Ok).SelectMany(r => new[] { r.Subtitles, r.Audio })]);
}
Expand Down Expand Up @@ -420,12 +434,7 @@ private void OpenResultFolder(IReadOnlyList<string?> outputs)

/// <summary>El progreso de un solo archivo, en la misma barra que la cola.</summary>
private static IProgress<JobProgress> Single(IProgress<BatchProgress> progress, string file) =>
new Relay(p => progress.Report(new BatchProgress(0, 1, file, p)));

private sealed class Relay(Action<JobProgress> report) : IProgress<JobProgress>
{
public void Report(JobProgress value) => report(value);
}
new SyncProgress<JobProgress>(p => progress.Report(new BatchProgress(0, 1, file, p)));

/// <summary>Corre una etapa con progreso y Cancelar; los errores previstos salen en la barra, en español.</summary>
private async Task<T?> BusyAsync<T>(Func<IProgress<BatchProgress>, CancellationToken, Task<T>> work)
Expand Down Expand Up @@ -462,7 +471,10 @@ private sealed class Relay(Action<JobProgress> report) : IProgress<JobProgress>
try
{
T result = await work(progress, _cts.Token);
DoneStory.Begin();
if (!_cts.IsCancellationRequested)
{
DoneStory.Begin();
}
return result;
}
catch (Exception ex)
Expand Down
68 changes: 49 additions & 19 deletions src/Subtitular.Core/JobBatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ public string Status(TimeSpan elapsed)
}
}

/// <summary>Cómo acabó un archivo de la cola: sus salidas o su error (en español si era previsto).</summary>
public sealed record BatchResult(JobOptions Options, Transcript? Transcript, string? Subtitles, string? Audio, string? Error)
/// <summary>Cómo acabó un archivo de la cola: sus salidas, su error (en español si era previsto) o que se canceló antes de terminar.</summary>
public sealed record BatchResult(JobOptions Options, Transcript? Transcript, string? Subtitles, string? Audio, string? Error, bool Cancelled = false)
{
public bool Ok => Error is null;
public bool Ok => Error is null && !Cancelled;
}

/// <summary>Varios archivos uno tras otro, con los mismos ajustes; cada uno se guarda al terminar.</summary>
Expand All @@ -38,32 +38,56 @@ public static class JobBatch
}

/// <summary>
/// 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
/// <c>CancellationToken</c>) y se lanza <see cref="OperationCanceledException"/>.
/// La cola sin repetidos (misma ruta completa) y sin que dos trabajos escriban la misma salida: el segundo «clip.mp4»
/// de otra carpeta con la carpeta de salida fijada pasa a «clip-2», el tercero a «clip-3».
/// </summary>
public static IReadOnlyList<JobOptions> Prepare(IReadOnlyList<JobOptions> jobs)
{
ArgumentNullException.ThrowIfNull(jobs);
List<JobOptions> unique = [.. jobs.DistinctBy(j => Path.GetFullPath(j.InputFile), StringComparer.OrdinalIgnoreCase)];
HashSet<string> outputs = new(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < unique.Count; i++)
{
string name = Path.GetFileNameWithoutExtension(unique[i].InputFile);
for (int n = 2; !outputs.Add(SubtitleJob.OutputPath(unique[i], "")); n++)
{
unique[i] = unique[i] with { OutputName = $"{name}-{n}" };
}
}
return unique;
}

/// <summary>
/// Procesa los archivos en orden (tras <see cref="Prepare"/>). 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 <c>CancellationToken</c>)
/// y vuelve, con los que faltaban, marcado <see cref="BatchResult.Cancelled"/>; lo ya guardado se devuelve igual.
/// </summary>
public static async Task<IReadOnlyList<BatchResult>> RunAsync(IReadOnlyList<JobOptions> jobs, IProgress<BatchProgress>? progress = null,
CancellationToken cancellationToken = default, Worker? worker = null)
{
ArgumentNullException.ThrowIfNull(jobs);
jobs = Prepare(jobs);
worker ??= RunAndSaveAsync;
List<BatchResult> results = new(jobs.Count);
for (int i = 0; i < jobs.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
JobOptions options = jobs[i];
if (cancellationToken.IsCancellationRequested)
{
results.Add(new BatchResult(options, null, null, null, null, Cancelled: true));
continue;
}
int index = i;
progress?.Report(new BatchProgress(index, jobs.Count, options.InputFile, null));
// Relevo síncrono (no Progress<T>, que encola): el de fuera ya lleva cada aviso a su hilo.
Relay stage = new(p => progress?.Report(new BatchProgress(index, jobs.Count, options.InputFile, p)));
SyncProgress<JobProgress> 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;
results.Add(new BatchResult(options, null, null, null, null, Cancelled: true));
}
catch (Exception ex)
{
Expand All @@ -73,22 +97,28 @@ public static async Task<IReadOnlyList<BatchResult>> RunAsync(IReadOnlyList<JobO
return results;
}

private sealed class Relay(Action<JobProgress> report) : IProgress<JobProgress>
{
public void Report(JobProgress value) => report(value);
}

/// <summary>Resumen para la barra: «5 archivos listos» o «3 archivos listos, 2 con error: a.mp4, b.mkv».</summary>
/// <summary>
/// Resumen para la barra: «5 archivos listos», «3 archivos listos, 2 con error: a.mp4, b.mkv» o, si se canceló,
/// «3 de 5 listos, cancelado en d.mp4».
/// </summary>
public static string Summary(IReadOnlyList<BatchResult> results)
{
ArgumentNullException.ThrowIfNull(results);
int ok = results.Count(r => r.Ok);
string listos = ok == 1 ? "1 archivo listo" : $"{ok} archivos listos";
List<string> failed = [.. results.Where(r => !r.Ok).Select(r => Path.GetFileName(r.Options.InputFile))];
return failed.Count == 0 ? listos : $"{listos}, {failed.Count} con error: {string.Join(", ", failed)}";
string ready = results.FirstOrDefault(r => r.Cancelled) is { } cancelled
? $"{ok} de {results.Count} listos, cancelado en {Path.GetFileName(cancelled.Options.InputFile)}"
: ok == 1 ? "1 archivo listo" : $"{ok} archivos listos";
List<string> failed = [.. results.Where(r => r.Error is not null).Select(r => Path.GetFileName(r.Options.InputFile))];
return failed.Count == 0 ? ready : $"{ready}, {failed.Count} con error: {string.Join(", ", failed)}";
}
}

/// <summary>Avisa en el mismo hilo (no <see cref="Progress{T}"/>, que encola): quien lo recibe ya lleva cada aviso a su hilo.</summary>
public sealed class SyncProgress<T>(Action<T> report) : IProgress<T>
{
public void Report(T value) => report(value);
}

/// <summary>Dónde quedó el resultado, para abrirlo en el Explorador al terminar.</summary>
public static class ResultFolder
{
Expand Down
11 changes: 6 additions & 5 deletions src/Subtitular.Core/Preferences.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ public sealed record Preferences(
public static string DefaultPath =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dathinel", "Subtitular", "preferencias.json");

/// <summary>Las guardadas, o las de fábrica si no hay archivo o está roto. Las carpetas que ya no existen se olvidan.</summary>
/// <summary>
/// Las guardadas, o las de fábrica si no hay archivo o está roto. Una carpeta que no exista en este arranque (disco externo o
/// de red desconectado) se conserva: el diálogo ya cae a la carpeta de siempre si no está.
/// </summary>
public static Preferences Load(string path)
{
Preferences p;
Expand All @@ -38,17 +41,15 @@ public static Preferences Load(string path)
}
return p with
{
LastInputDir = ExistingDir(p.LastInputDir),
OutputDir = ExistingDir(p.OutputDir),
LastInputDir = string.IsNullOrWhiteSpace(p.LastInputDir) ? null : p.LastInputDir,
OutputDir = string.IsNullOrWhiteSpace(p.OutputDir) ? null : p.OutputDir,
Language = string.IsNullOrWhiteSpace(p.Language) ? "auto" : p.Language,
Format = p.Format is "srt" or "vtt" ? p.Format : "srt",
TranslateTo = string.IsNullOrWhiteSpace(p.TranslateTo) ? null : p.TranslateTo,
SkippedVersion = string.IsNullOrWhiteSpace(p.SkippedVersion) ? null : p.SkippedVersion,
};
}

private static string? ExistingDir(string? dir) => !string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir) ? dir : null;

/// <summary>Si no se puede escribir (disco lleno, permisos) se pierde el recuerdo, no la sesión.</summary>
public void Save(string path)
{
Expand Down
4 changes: 3 additions & 1 deletion src/Subtitular.Core/SubtitleJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public sealed record JobOptions(string InputFile)
public string Format { get; init; } = "srt";
/// <summary>Carpeta de salida; nula = junto al archivo de entrada.</summary>
public string? OutputDir { get; init; }
/// <summary>Nombre base de las salidas; nulo = el del archivo de entrada. La cola lo usa para no pisar dos «clip.mp4».</summary>
public string? OutputName { get; init; }
public string? TranslateTo { get; init; }
public bool Speak { get; init; }
public string Device { get; init; } = "auto";
Expand Down Expand Up @@ -85,7 +87,7 @@ public static string OutputPath(JobOptions options, string extension)
{
ArgumentNullException.ThrowIfNull(options);
string dir = string.IsNullOrWhiteSpace(options.OutputDir) ? Path.GetDirectoryName(Path.GetFullPath(options.InputFile))! : options.OutputDir;
string name = Path.GetFileNameWithoutExtension(options.InputFile);
string name = options.OutputName ?? Path.GetFileNameWithoutExtension(options.InputFile);
if (!string.IsNullOrWhiteSpace(options.TranslateTo))
{
name += "." + options.TranslateTo;
Expand Down
39 changes: 38 additions & 1 deletion tests/Subtitular.Tests/BatchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,45 @@ public async Task Cancel_stops_the_queue_at_the_current_file()
await Task.Delay(Timeout.Infinite, token);
return (Empty, "", null);
});
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => run);
IReadOnlyList<BatchResult> results = await run;
Assert.Equal(1, started);
Assert.All(results, r => Assert.True(r.Cancelled && !r.Ok));
Assert.Equal("0 de 2 listos, cancelado en a.mp4", JobBatch.Summary(results));
}

[Fact]
public async Task Cancel_keeps_the_files_already_saved()
{
using CancellationTokenSource cts = new();
IReadOnlyList<BatchResult> results = await JobBatch.RunAsync([Job("a.mp4"), Job("b.mp4"), Job("c.mp4")], null, cts.Token, (o, _, token) =>
{
if (o.InputFile.EndsWith("b.mp4", StringComparison.Ordinal))
{
cts.Cancel();
token.ThrowIfCancellationRequested();
}
return Task.FromResult((Empty, SubtitleJob.OutputPath(o, ".srt"), (string?)null));
});
Assert.Equal([true, false, false], results.Select(r => r.Ok));
Assert.Equal(Path.Combine(Path.GetTempPath(), "a.srt"), results[0].Subtitles);
Assert.Equal("1 de 3 listos, cancelado en b.mp4", JobBatch.Summary(results));
}

[Fact]
public void Prepare_drops_repeated_inputs_and_renames_outputs_that_collide()
{
string outDir = Path.Combine(Path.GetTempPath(), "subs");
JobOptions a = new(Path.Combine(Path.GetTempPath(), "a", "clip.mp4")) { OutputDir = outDir };
JobOptions b = new(Path.Combine(Path.GetTempPath(), "b", "clip.mp4")) { OutputDir = outDir };
JobOptions c = new(Path.Combine(Path.GetTempPath(), "c", "clip.mkv")) { OutputDir = outDir };
IReadOnlyList<JobOptions> prepared = JobBatch.Prepare([a, a with { Format = "vtt" }, b, c]);

Assert.Equal(3, prepared.Count);
Assert.Equal(Path.Combine(outDir, "clip.srt"), SubtitleJob.OutputPath(prepared[0], ".srt"));
Assert.Equal(Path.Combine(outDir, "clip-2.srt"), SubtitleJob.OutputPath(prepared[1], ".srt"));
Assert.Equal(Path.Combine(outDir, "clip-3.srt"), SubtitleJob.OutputPath(prepared[2], ".srt"));
// Junto al video no chocan: cada uno queda en su carpeta con su nombre.
Assert.All(JobBatch.Prepare([a with { OutputDir = null }, b with { OutputDir = null }]), j => Assert.Null(j.OutputName));
}

[Fact]
Expand Down
Loading
Loading