using Nikse.SubtitleEdit.UiLogic.AutoTranslate; using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.UiLogic.Translate; using Nikse.SubtitleEdit.UiLogic.LlamaCpp; namespace SeConv.Core; /// /// Headless auto-translate for --translate-to. Wraps libse's /// engines plus libuilogic's merge/split translate loop (shared with the UI's batch convert). /// /// llama.cpp gets special treatment: with no --translate-url the runner finds the /// llama-server binary (Subtitle Edit's data folder next to seconv, the installed SE data /// folder, then the system PATH) and an installed .gguf model, starts the server on a /// free loopback port, and lets kill it at process exit. /// seconv never downloads engines or models (consistent with its Tesseract/Paddle policy) — /// missing pieces fail fast with instructions instead. /// internal sealed class AutoTranslateRunner { public static readonly string[] SupportedEngines = { "llamacpp", "ollama", "lmstudio", "libretranslate", "nllb-serve", "nllb-api" }; /// /// Engines that build their request from an editable prompt, i.e. the ones /// --translate-prompt can steer. The rest (LibreTranslate, NLLB) are translation /// services with no prompt at all. /// public static readonly string[] PromptEngines = { "llamacpp", "ollama", "lmstudio" }; /// File extensions that make --translate-prompt a file path rather than inline text. private static readonly string[] PromptFileExtensions = { ".txt", ".prompt", ".md" }; /// Refuse to read a whole video/model file as a prompt when a path points at one. private const long MaxPromptFileBytes = 256 * 1024; private readonly ConversionOptions _options; private readonly IAutoTranslator _translator; private readonly LlamaCppModel? _llamaCppModel; // non-null = local server mode; start before first use private readonly string _engine; private readonly string? _prompt; // --translate-prompt, already read from file / unescaped /// The resolved local llama.cpp model, exposed for tests. internal LlamaCppModel? LlamaCppModel => _llamaCppModel; private string? _targetLanguageCode; /// /// The target language code as the engine knows it ("de", "zh-CN") - --translate-to /// also accepts English names ("German"), and the output file name needs the code. /// Null when the requested language is unknown; TranslateAsync reports that error. /// public string? TargetLanguageCode { get { if (_targetLanguageCode == null) { try { _targetLanguageCode = ResolveLanguage(_translator.GetSupportedTargetLanguages(), _options.TranslateTo!, "target").Code; } catch (InvalidOperationException) { _targetLanguageCode = string.Empty; } } return _targetLanguageCode.Length == 0 ? null : _targetLanguageCode; } } private AutoTranslateRunner(ConversionOptions options, IAutoTranslator translator, LlamaCppModel? llamaCppModel, string engine, string? prompt) { _options = options; _translator = translator; _llamaCppModel = llamaCppModel; _engine = engine; _prompt = prompt; } /// /// Validates the engine choice, applies URL/model options to libse's Configuration, and /// (for local llama.cpp) resolves the server binary + model up front so a broken setup /// fails before any file is converted. Throws /// with an actionable message. /// public static AutoTranslateRunner Create(ConversionOptions options) { var engine = NormalizeEngine(options.TranslateEngine); var url = options.TranslateUrl?.Trim(); var tools = Configuration.Settings.Tools; LlamaCppModel? llamaCppModel = null; var prompt = ReadPromptOption(options.TranslatePrompt); if (prompt != null && !SupportsPrompt(engine)) { throw new InvalidOperationException( $"--translate-prompt is not supported by translate engine '{engine}'. Use one of: {string.Join(", ", PromptEngines)}."); } if (options.Verbose) { LlamaCppServerManager.LogAction = m => Console.WriteLine(" " + m); } IAutoTranslator translator; switch (engine) { case "llamacpp": translator = new LlamaCppTranslate(); if (!string.IsNullOrEmpty(url)) { // User-managed server. LlamaCppTranslate posts to the URL as-is, so accept a // bare host:port or an OpenAI-style ".../v1" base and complete either to the // chat/completions endpoint (without doubling an already-present /v1). tools.LlamaCppApiUrl = CompleteChatCompletionsUrl(url); } else { llamaCppModel = ResolveLocalLlamaCpp(options.TranslateModel); } break; case "ollama": translator = new OllamaTranslate(); if (!string.IsNullOrEmpty(url)) { tools.OllamaApiUrl = url; } if (!string.IsNullOrWhiteSpace(options.TranslateModel)) { tools.OllamaModel = options.TranslateModel.Trim(); } if (prompt != null) { tools.OllamaPrompt = prompt; } break; case "lmstudio": translator = new LmStudioTranslate(); tools.LmStudioApiUrl = string.IsNullOrEmpty(url) ? "http://localhost:1234/v1/chat/completions" : url; if (!string.IsNullOrWhiteSpace(options.TranslateModel)) { tools.LmStudioModel = options.TranslateModel.Trim(); } if (prompt != null) { tools.LmStudioPrompt = prompt; } break; case "libretranslate": translator = new LibreTranslate(); if (!string.IsNullOrEmpty(url)) { tools.AutoTranslateLibreUrl = url; } break; case "nllb-serve": translator = new NoLanguageLeftBehindServe(); if (!string.IsNullOrEmpty(url)) { tools.AutoTranslateNllbServeUrl = url; } break; case "nllb-api": translator = new NoLanguageLeftBehindApi(); if (!string.IsNullOrEmpty(url)) { tools.AutoTranslateNllbApiUrl = url; } break; default: throw new InvalidOperationException( $"Translate engine '{options.TranslateEngine}' is not supported. Use one of: {string.Join(", ", SupportedEngines)}."); } var runner = new AutoTranslateRunner(options, translator, llamaCppModel, engine, prompt); runner.ApplyPromptOverride(); return runner; } /// Canonical engine id: empty means the default (llamacpp), and llama.cpp/llama are aliases for it. internal static string NormalizeEngine(string? engine) { var name = string.IsNullOrWhiteSpace(engine) ? "llamacpp" : engine.Trim().ToLowerInvariant(); return name is "llama.cpp" or "llama" ? "llamacpp" : name; } /// True when the engine builds its request from an editable prompt (see ). public static bool SupportsPrompt(string? engine) { return PromptEngines.Contains(NormalizeEngine(engine)); } /// /// Resolves a prompt option's value to the prompt text, or null when the option was not /// given. A value ending in .txt/.prompt/.md, or naming a file that /// exists, is read from disk - completion templates are multi-line and a shell cannot always /// pass those as one argument. Inline text gets \n, \r, \t and /// \\ unescaped for the same reason. /// /// Shared by --translate-prompt and --ocr-prompt; /// and only shape the error messages and the "is this a path /// or a sentence?" hint, so both options behave identically. /// /// internal static string? ReadPromptOption(string? value, string optionName = "--translate-prompt", string placeholders = "{0}/{1}/{2}") { if (value == null) { return null; } var trimmed = value.Trim(); if (trimmed.Length == 0) { throw new InvalidOperationException($"{optionName} is empty. Pass the prompt text or the path to a text file."); } var exists = FileExistsSafe(trimmed); if (exists || LooksLikePromptFile(trimmed)) { if (!exists) { throw new InvalidOperationException( $"Prompt file not found: {trimmed}. " + $"A {optionName} value with no spaces, or ending in .txt/.prompt/.md, is read as a file path; " + $"prompt text passed inline has to contain a space or a {placeholders} placeholder."); } var size = new FileInfo(trimmed).Length; if (size > MaxPromptFileBytes) { throw new InvalidOperationException( $"Prompt file is too large ({size / 1024} KB, max {MaxPromptFileBytes / 1024} KB): {trimmed}. " + $"{optionName} takes the prompt itself, not a data file."); } var fromFile = File.ReadAllText(trimmed).Trim(); if (fromFile.Length == 0) { throw new InvalidOperationException($"Prompt file is empty: {trimmed}"); } return fromFile; } return Unescape(value.Trim('\r', '\n')); } /// /// Whether a value that is not an existing file was still meant as one - a typo'd path must /// fail loudly instead of being handed to the model as the prompt, which silently translates /// a whole batch under "prompts/mine.tmpl". A real prompt is a sentence: it contains a space, /// a line break, or at least a {0}/{1}/{2} placeholder. Anything else - /// or anything with a prompt-file extension - is treated as a path. /// private static bool LooksLikePromptFile(string value) { if (value.Any(char.IsWhiteSpace)) { // Spaces or line breaks: only a prompt-file extension still makes it a path // ("my prompts/milmmt.txt"), never a sentence. return !value.Contains('\n') && !value.Contains('\r') && !value.Contains('{') && PromptFileExtensions.Any(e => value.EndsWith(e, StringComparison.OrdinalIgnoreCase)); } return !value.Contains('{'); } private static bool FileExistsSafe(string path) { try { return File.Exists(path); } catch (Exception) { // A prompt sentence is not a path - too long, invalid characters, ... return false; } } private static string Unescape(string text) { if (!text.Contains('\\')) { return text; } var sb = new System.Text.StringBuilder(text.Length); for (var i = 0; i < text.Length; i++) { if (text[i] != '\\' || i == text.Length - 1) { sb.Append(text[i]); continue; } i++; switch (text[i]) { case 'n': sb.Append('\n'); break; case 'r': sb.Append('\r'); break; case 't': sb.Append('\t'); break; case '\\': sb.Append('\\'); break; default: sb.Append('\\').Append(text[i]); break; } } return sb.ToString(); } /// /// Writes --translate-prompt into the settings field the selected engine reads. /// For llama.cpp that means both prompt fields: the engine prefers the per-model /// template (Tools.LlamaCppModelPrompt, set from the curated model by /// before every file), so /// setting only the plain prompt would be silently ignored for MiLMMT/Hy-MT2 and friends. /// Called again after each ApplyTranslatePromptSettings for that reason. /// /// /// Prompt/sampling settings for the upcoming file. The engine reads the per-model /// prompt/sampling (e.g. Hy-MT2's or MiLMMT-46's trained-in prompt) from settings, which /// nothing in a console run sets otherwise - and an explicit --translate-prompt /// overrides it again. Internal so the precedence can be tested without a llama-server. /// internal void ApplyPromptSettings() { if (_llamaCppModel != null) { LlamaCppServerManager.ApplyTranslatePromptSettings(_llamaCppModel); } ApplyPromptOverride(); } private void ApplyPromptOverride() { if (_prompt == null || _engine != "llamacpp") { return; // ollama/lmstudio are set once in Create; nothing overwrites them later } Configuration.Settings.Tools.LlamaCppPrompt = _prompt; Configuration.Settings.Tools.LlamaCppModelPrompt = _prompt; } /// /// Translates all paragraphs in place. Reuses the already-running llama-server across /// files in the same run (the server manager is a no-op when the model matches). /// public async Task TranslateAsync(Subtitle subtitle, CancellationToken cancellationToken) { ApplyPromptSettings(); if (_llamaCppModel != null) { if (!LlamaCppServerManager.IsServerRunning) { if (!_options.Quiet) { Console.WriteLine($" Starting llama-server with model {Path.GetFileName(_llamaCppModel.FileName)} (stops at exit)..."); } await LlamaCppServerManager.EnsureServerRunningAsync(_llamaCppModel, cancellationToken); } } var sourceCode = _options.TranslateFrom; if (string.IsNullOrWhiteSpace(sourceCode)) { sourceCode = LanguageAutoDetect.AutoDetectGoogleLanguage(subtitle); } var source = ResolveLanguage(_translator.GetSupportedSourceLanguages(), sourceCode!, "source"); var target = ResolveLanguage(_translator.GetSupportedTargetLanguages(), _options.TranslateTo!, "target"); if (!_options.Quiet) { Console.WriteLine($" Translating {source.Name} -> {target.Name} via {_translator.Name}..."); } var doTranslate = new DoAutoTranslate(); if (!_options.Quiet) { doTranslate.Progress = (done, total) => ProgressLine.Report("Translated", done, total); } var rows = await doTranslate.DoTranslate(subtitle, source, target, _translator, cancellationToken); if (!_options.Quiet) { ProgressLine.Finish(); } for (var i = 0; i < subtitle.Paragraphs.Count && i < rows.Count; i++) { if (!string.IsNullOrWhiteSpace(rows[i].TranslatedText)) { subtitle.Paragraphs[i].Text = rows[i].TranslatedText; } } } /// /// Completes a user-supplied --translate-url to the full chat/completions endpoint. /// Accepts a bare host:port, an OpenAI-style .../v1 base (with or without /// trailing slash), or an already-complete .../chat/completions URL. /// internal static string CompleteChatCompletionsUrl(string url) { return AutoTranslateUrl.Complete(url, LlamaCppTranslate.DefaultUrl); } internal static TranslationPair ResolveLanguage(List languages, string requested, string kind) { var match = languages.FirstOrDefault(p => p.Code.Equals(requested, StringComparison.OrdinalIgnoreCase)) ?? languages.FirstOrDefault(p => p.TwoLetterIsoLanguageName.Equals(requested, StringComparison.OrdinalIgnoreCase)) ?? languages.FirstOrDefault(p => p.Name.Equals(requested, StringComparison.OrdinalIgnoreCase)); if (match == null) { var known = string.Join(", ", languages.Select(p => p.Code).Take(30)); throw new InvalidOperationException( $"Unknown {kind} language '{requested}' for this translate engine. Use a code or English name; codes include: {known}..."); } return match; } /// /// Local llama.cpp mode: resolve the llama-server binary and a translate model without /// downloading anything. The Subtitle Edit data folders are probed first so an install /// done via the SE GUI (Auto-translate > llama.cpp) is picked up automatically. /// private static LlamaCppModel ResolveLocalLlamaCpp(string? requestedModel) { LlamaCppLocal.EnsureServerBinary("Auto-translate > llama.cpp", "--translate-url"); return ResolveLlamaCppModel(requestedModel); } private static LlamaCppModel ResolveLlamaCppModel(string? requestedModel) { if (!string.IsNullOrWhiteSpace(requestedModel)) { var name = requestedModel.Trim(); // Full path to a .gguf: use it directly, but infer the chat-template flags from the file // name (TranslateGemma/Qwen need them, whether or not we curate that exact quant). if (Path.IsPathRooted(name) || name.Contains(Path.DirectorySeparatorChar) || name.Contains(Path.AltDirectorySeparatorChar)) { if (!File.Exists(name)) { throw new InvalidOperationException($"Translate model file not found: {name}"); } var fileName = Path.GetFileName(name); return LlamaCppServerManager.CreateCustomModel(fileName, Path.GetFullPath(name), string.Empty); } // Name: match curated + custom models in the models folder (with or without .gguf). var all = LlamaCppServerManager.GetAllTranslateModels(); var model = all.FirstOrDefault(m => m.FileName.Equals(name, StringComparison.OrdinalIgnoreCase)) ?? all.FirstOrDefault(m => m.FileName.Equals(name + ".gguf", StringComparison.OrdinalIgnoreCase)) ?? all.FirstOrDefault(m => m.DisplayName.Equals(name, StringComparison.OrdinalIgnoreCase)); if (model == null || !LlamaCppServerManager.IsModelInstalled(model)) { throw new InvalidOperationException( $"Translate model '{name}' not found in {LlamaCppServerManager.GetAndCreateModelsFolder()}. " + "Download one in Subtitle Edit (Auto-translate > llama.cpp), drop a .gguf into that folder, " + "or pass a full path via --translate-model."); } return model; } // No model given: pick the first installed translate model (curated order, then custom). var installed = LlamaCppServerManager.GetAllTranslateModels().FirstOrDefault(LlamaCppServerManager.IsModelInstalled); if (installed == null) { throw new InvalidOperationException( $"No llama.cpp translate model found in {LlamaCppServerManager.GetAndCreateModelsFolder()}. " + "Download one in Subtitle Edit (Auto-translate > llama.cpp), drop a .gguf into that folder, " + "or pass --translate-model:."); } return installed; } }