From a9c778ecd8a6962f01102c31d2fb36cb6c10b7ef Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Thu, 2 Jul 2026 13:22:50 +0200 Subject: [PATCH 01/13] Implement ollama support for subtitle translation --- package.json | 2 +- public/api/client.ts | 17 + public/config/options.ts | 3 +- public/features/settings-controls.ts | 25 ++ public/features/settings-form.ts | 46 +++ public/index.html | 38 +++ public/types.ts | 17 + src/ass-edit.ts | 189 +++++++++++ src/config.ts | 8 + src/encoder.ts | 121 ++++++- src/index.ts | 27 ++ src/ollama.ts | 170 ++++++++++ src/settings-code.ts | 8 + src/srt-edit.ts | 86 +++++ src/store.ts | 9 + src/subtitle-translate.ts | 294 +++++++++++++++++ src/tracks.ts | 5 +- src/translate-languages.ts | 476 +++++++++++++++++++++++++++ src/translate-step.ts | 333 +++++++++++++++++++ src/types.ts | 17 + tests/chunks.test.ts | 76 +++++ tests/ollama.test.ts | 119 +++++++ tests/ordering.test.ts | 55 ++++ tests/subtitle-edit.test.ts | 134 ++++++++ tests/translate-step.test.ts | 33 ++ 25 files changed, 2294 insertions(+), 14 deletions(-) create mode 100644 src/ass-edit.ts create mode 100644 src/ollama.ts create mode 100644 src/srt-edit.ts create mode 100644 src/subtitle-translate.ts create mode 100644 src/translate-languages.ts create mode 100644 src/translate-step.ts create mode 100644 tests/chunks.test.ts create mode 100644 tests/ollama.test.ts create mode 100644 tests/ordering.test.ts create mode 100644 tests/subtitle-edit.test.ts create mode 100644 tests/translate-step.test.ts diff --git a/package.json b/package.json index 43e17fb..f99bddb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rabbit-encoder", - "version": "7.3.1", + "version": "7.4.0", "type": "module", "scripts": { "dev": "bun run --watch src/index.ts", diff --git a/public/api/client.ts b/public/api/client.ts index b13a6d6..f76e06f 100644 --- a/public/api/client.ts +++ b/public/api/client.ts @@ -393,3 +393,20 @@ export async function fetchVsDefaultEntry(presetId: string): Promise { + try { + const res = await authFetch(`${API}/api/translate/test`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url, model, target }), + }); + return await res.json(); + } catch (err: any) { + return { ok: false, error: err?.message || "Request failed" }; + } +} diff --git a/public/config/options.ts b/public/config/options.ts index 400f29a..c2ba5f1 100644 --- a/public/config/options.ts +++ b/public/config/options.ts @@ -11,7 +11,6 @@ import type { EncoderSpeed, StyleAppearance, SubtitleProcessingMode, - SubtitleStyle, VideoEncodeMode, } from "../types"; import type { PipelinePreset } from "../ui/models"; @@ -87,6 +86,8 @@ export const PIPELINE_PRESET_HELP: Record = { custom: "Configure each pipeline stage individually below.", }; +export const TRANSLATE_MODEL_OPTIONS = ["translategemma:4b", "translategemma:12b", "translategemma:27b"]; + export const DEFAULT_STYLE_APPEARANCE: StyleAppearance = { fontSize: 80, primaryColour: "&H00FFFFFF", diff --git a/public/features/settings-controls.ts b/public/features/settings-controls.ts index 0636685..776b1df 100644 --- a/public/features/settings-controls.ts +++ b/public/features/settings-controls.ts @@ -520,6 +520,31 @@ export function renderLanguageFilterInput(container: HTMLElement, value: string[ container.appendChild(hint); } +export function renderTranslationLanguagesInput(container: HTMLElement, value: string[], onChange: (value: string[]) => void): void { + container.innerHTML = ""; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "lang-filter-input"; + input.placeholder = "jpn, eng"; + input.value = (value || []).join(", "); + + const hint = document.createElement("div"); + hint.className = "lang-filter-hint"; + hint.textContent = "Comma-separated ISO codes."; + + input.oninput = () => + onChange( + input.value + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0), + ); + + container.appendChild(input); + container.appendChild(hint); +} + export function renderNlmeansParamsEditor(container: HTMLElement, params: NlmeansLevelParams, onChange: (value: NlmeansLevelParams) => void): void { container.innerHTML = ""; const grid = document.createElement("div"); diff --git a/public/features/settings-form.ts b/public/features/settings-form.ts index 5895671..4e4ee12 100644 --- a/public/features/settings-form.ts +++ b/public/features/settings-form.ts @@ -17,6 +17,7 @@ import { SUBTITLE_FORMAT_PRIORITY_OPTIONS, SUBTITLE_PROCESSING_OPTIONS, SUBTITLE_SOURCE_PRIORITY_OPTIONS, + TRANSLATE_MODEL_OPTIONS, VIDEO_ENCODE_OPTIONS, } from "../config/options"; import { @@ -40,9 +41,12 @@ import { renderSubtitleConfidenceControl, renderSubtitleLangDetectControl, renderSubtitleStyleTargets, + renderTextControl, + renderTranslationLanguagesInput, wireEncoderControls, } from "./settings-controls"; import { byId } from "../shared/dom"; +import { testTranslateConnection } from "../api/client"; export type SettingsFormPrefix = "default" | "job"; @@ -311,4 +315,46 @@ export function renderSettingsForm(prefix: SettingsFormPrefix, settings: JobSett renderAudioLanguagesInput(el("audio-languages"), settings.audioLanguages || [], (v) => (settings.audioLanguages = v)); renderLanguageFilterInput(el("subtitle-languages"), settings.subtitleLanguages || [], (v) => (settings.subtitleLanguages = v)); renderBitrateInputs(el("bitrates"), settings.audioBitrates, (ch, val) => (settings.audioBitrates[ch] = val)); + + renderLabeledToggle(el("translate-enabled"), settings.translateSubtitles, "Translate missing languages", (v) => { + settings.translateSubtitles = v; + }); + + renderTextControl(el("translate-url"), "Ollama URL", settings.translateOllamaUrl, "http://localhost:11434", (v) => { + settings.translateOllamaUrl = v.trim(); + }); + + renderRadioPills(el("translate-model"), TRANSLATE_MODEL_OPTIONS, settings.translateModel, (v) => { + settings.translateModel = v; + }); + + renderTranslationLanguagesInput(el("translate-targets"), settings.translateTargetLanguages, (langs) => { + settings.translateTargetLanguages = langs; + }); + + renderNumberControl(el("translate-batch"), "Dialogs per request", settings.translateBatchSize, { min: 1, max: 1000, step: 1 }, (v) => { + settings.translateBatchSize = v; + }); + + renderLabeledToggle(el("translate-signs"), settings.translateSignsSongs, "Also translate signs & songs", (v) => { + settings.translateSignsSongs = v; + }); + + const testBtn = el("translate-test") as HTMLButtonElement; + const testResult = el("translate-test-result"); + testBtn.addEventListener("click", async () => { + testBtn.disabled = true; + testResult.textContent = "Testing…"; + testResult.className = "test-result"; + const firstTarget = settings.translateTargetLanguages[0]; + const r = await testTranslateConnection(settings.translateOllamaUrl, settings.translateModel, firstTarget); + testBtn.disabled = false; + if (r.ok) { + testResult.textContent = `✓ OK - sample (${r.target}): "${r.sample}"`; + testResult.classList.add("ok"); + } else { + testResult.textContent = `✗ ${r.error}`; + testResult.classList.add("error"); + } + }); } diff --git a/public/index.html b/public/index.html index dff7a79..ca2364b 100644 --- a/public/index.html +++ b/public/index.html @@ -279,6 +279,25 @@

Default Settings

+
+ Subtitle translation +
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
@@ -512,6 +531,25 @@

Job Settings

+
+ Subtitle translation +
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
diff --git a/public/types.ts b/public/types.ts index 8243576..16e9271 100644 --- a/public/types.ts +++ b/public/types.ts @@ -196,6 +196,23 @@ export interface JobSettings { removeUnusedFonts: boolean; /** Selected font group (folder label under the user fonts dir). */ fontGroup: string; + // Subtitle translation (Ollama / TranslateGemma) + /** Master switch: translate missing target languages via Ollama. */ + translateSubtitles: boolean; + /** Ollama base URL (http://localhost:11434) */ + translateOllamaUrl: string; + /** Model tag (translategemma:12b) */ + translateModel: string; + /** Languages to ensure exist (["eng","deu","fra","slv"]). */ + translateTargetLanguages: string[]; + /** Dialogs sent to the model per request. */ + translateBatchSize: number; + /** Also translate sign/song lines (keeps signs consistent in the target track). */ + translateSignsSongs: boolean; + /** Ollama context window (num_ctx). TranslateGemma supports up to 131072. */ + translateNumCtx: number; + /** Per-request timeout, ms. */ + translateTimeoutMs: number; /** * Ordered list of VapourSynth filter passes to apply during the prepare * stage, before the FFmpeg -vf chain. Each entry references a preset by diff --git a/src/ass-edit.ts b/src/ass-edit.ts new file mode 100644 index 0000000..74b5659 --- /dev/null +++ b/src/ass-edit.ts @@ -0,0 +1,189 @@ +/** + * Lossless ASS event editing for translation. + * + * The classifier's parser (ass-classifier.ts) intentionally discards + * everything except style + text, which is fine for classification but useless + * for rebuilding. This module keeps each `Dialogue:` line's exact prefix + * (layer, timing, style, actor, margins, effect) so the translator can swap + * only the Text field and reassemble the file byte-for-byte everywhere else. + * + * Tag handling follows the agreed rule: preserve a leading override block and + * literal "\N" breaks; drop mid-text override tags on translated lines. Drawing + * lines (\p1 ...) and tag-only/empty lines are marked non-translatable so we + * never feed vector coordinates to the model. + */ + +export interface AssEventLine { + /** Index into the file's line array (from split on \r?\n). */ + lineNo: number; + /** Style name for this event. */ + style: string; + /** Start time in ms. */ + startMs: number; + /** End time in ms. */ + endMs: number; + /** The Text field, verbatim. */ + rawText: string; + /** + * Everything on the line up to and including the comma before Text + * (e.g. "Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,"). Concatenated + * with a (possibly new) text to rebuild the line. + */ + prefix: string; +} + +export interface ParsedAssEvents { + events: AssEventLine[]; + /** Total number of lines the file split into (sanity for rebuild). */ + lineCount: number; +} + +const NUM = (v: string | undefined, d: number): number => { + const n = parseFloat(v ?? ""); + return Number.isFinite(n) ? n : d; +}; + +/** Parse an ASS timecode "H:MM:SS.cc" (centiseconds) to milliseconds. */ +export function assTimeToMs(tc: string): number { + const m = tc.trim().match(/^(\d+):(\d{2}):(\d{2})[.:](\d{1,3})$/); + if (!m) return 0; + const cs = m[4]!.padEnd(2, "0").slice(0, 2); // normalize to centiseconds + return parseInt(m[1]!, 10) * 3_600_000 + parseInt(m[2]!, 10) * 60_000 + parseInt(m[3]!, 10) * 1_000 + parseInt(cs, 10) * 10; +} + +/** + * Parse every `Dialogue:` event, preserving line prefixes. `Comment:` events + * are ignored (not rendered, never translated). + */ +export function parseAssEvents(assText: string): ParsedAssEvents { + const lines = assText.split(/\r?\n/); + const events: AssEventLine[] = []; + + let section = ""; + let eventKeys: string[] = []; + let idxStart = -1; + let idxEnd = -1; + let idxStyle = -1; + let idxText = -1; + + for (let lineNo = 0; lineNo < lines.length; lineNo++) { + const raw = lines[lineNo]!; + const line = raw.trim(); + if (!line) continue; + + if (line.startsWith("[") && line.endsWith("]")) { + section = line.slice(1, -1).trim().toLowerCase(); + continue; + } + if (section !== "events") continue; + + const lower = line.toLowerCase(); + + if (lower.startsWith("format:")) { + eventKeys = line + .substring(line.indexOf(":") + 1) + .split(",") + .map((k) => k.trim().toLowerCase()); + idxStart = eventKeys.indexOf("start"); + idxEnd = eventKeys.indexOf("end"); + idxStyle = eventKeys.indexOf("style"); + idxText = eventKeys.indexOf("text"); + continue; + } + + if (!lower.startsWith("dialogue:")) continue; + if (eventKeys.length === 0 || idxText < 0) continue; + + // Split into exactly eventKeys.length fields; Text (last) keeps commas. + const afterPrefix = raw.substring(raw.indexOf(":") + 1); + const fields: string[] = []; + let remaining = afterPrefix; + for (let i = 0; i < eventKeys.length - 1; i++) { + const comma = remaining.indexOf(","); + if (comma < 0) { + fields.push(remaining); + remaining = ""; + break; + } + fields.push(remaining.slice(0, comma)); + remaining = remaining.slice(comma + 1); + } + fields.push(remaining); + if (fields.length < eventKeys.length) continue; + + // Reconstruct the prefix: the "Dialogue:" keyword plus every field up to + // (but not including) Text, with commas - verbatim from the source. + const keyword = raw.slice(0, raw.indexOf(":") + 1); // preserves original casing/spacing + const prefixFields = fields.slice(0, idxText); + const prefix = keyword + prefixFields.join(",") + ","; + const rawText = fields.slice(idxText).join(","); // Text may itself contain commas + + events.push({ + lineNo, + style: (fields[idxStyle] ?? "").trim(), + startMs: assTimeToMs(fields[idxStart] ?? ""), + endMs: assTimeToMs(fields[idxEnd] ?? ""), + rawText, + prefix, + }); + } + + return { events, lineCount: lines.length }; +} + +const LEADING_TAGS_RE = /^(?:\{[^}]*\})+/; +const ALL_TAGS_RE = /\{[^}]*\}/g; +const DRAWING_RE = /\\p[1-9]/i; // \p1.. enters drawing mode + +export interface AssTextParts { + /** Leading override block(s) to re-prepend after translation, or "". */ + lead: string; + /** Visible text with mid-text tags stripped; literal "\N" breaks kept. */ + visible: string; + /** False for drawings, tag-only, or empty lines - leave such lines as-is. */ + translatable: boolean; +} + +/** + * Split an ASS Text field into a leading tag block + translatable visible text. + * Mid-text override tags are removed (they're dropped on translated lines, per + * spec). Drawing lines and lines with no visible characters are flagged + * non-translatable. + */ +export function splitAssText(rawText: string): AssTextParts { + if (DRAWING_RE.test(rawText)) return { lead: "", visible: "", translatable: false }; + + const leadMatch = rawText.match(LEADING_TAGS_RE); + const lead = leadMatch ? leadMatch[0] : ""; + const rest = rawText.slice(lead.length); + const visible = rest.replace(ALL_TAGS_RE, ""); + + // Nothing to translate if only whitespace / "\N" breaks remain. + const stripped = visible.replace(/\\N/gi, "").trim(); + if (stripped.length === 0) return { lead, visible, translatable: false }; + + return { lead, visible, translatable: true }; +} + +/** Recombine a preserved lead block with translated visible text. */ +export function joinAssText(lead: string, translatedVisible: string): string { + return lead + translatedVisible; +} + +/** + * Rebuild the ASS document, replacing the Text of the given events (keyed by + * their `lineNo`) and leaving every other byte untouched. Line endings are + * normalized to "\n" (matching how the pipeline already re-materializes ASS). + */ +export function buildTranslatedAss(assText: string, newTextByLineNo: Map, events: AssEventLine[]): string { + const lines = assText.split(/\r?\n/); + const prefixByLineNo = new Map(); + for (const ev of events) prefixByLineNo.set(ev.lineNo, ev.prefix); + + for (const [lineNo, newText] of newTextByLineNo) { + const prefix = prefixByLineNo.get(lineNo); + if (prefix === undefined) continue; + lines[lineNo] = prefix + newText; + } + return lines.join("\n"); +} diff --git a/src/config.ts b/src/config.ts index 1dc30b2..58e5b17 100644 --- a/src/config.ts +++ b/src/config.ts @@ -82,6 +82,14 @@ const DEFAULT_JOB_SETTINGS: JobSettings = { removeUnusedFonts: false, fontGroup: "Noto Sans", audioBitrates: DEFAULT_BITRATES, + translateSubtitles: false, + translateOllamaUrl: "http://localhost:11434", + translateModel: "translategemma:12b", + translateTargetLanguages: [], + translateBatchSize: 40, + translateSignsSongs: true, + translateNumCtx: 8192, + translateTimeoutMs: 120000, vsFilters: [], }; diff --git a/src/encoder.ts b/src/encoder.ts index a1daddb..edc1511 100644 --- a/src/encoder.ts +++ b/src/encoder.ts @@ -36,6 +36,7 @@ import { cpus } from "os"; import { getEncoder } from "./encoders"; import { axisSuffix, chooseAvailableFontFamily, fontAttachmentFileName, instancedFontNames, instanceFont } from "./font-instance"; import { DEFAULT_STYLE_APPEARANCE, type StyleAppearance } from "./subtitle-style"; +import { runTranslateStep, orderOutputSubtitles, type TranslatedTrack } from "./translate-step"; export { CancelledError } from "./process"; @@ -47,7 +48,8 @@ const S_SCENES = 4; const S_ZONES = 5; const S_FINAL = 6; const S_AUDIO = 7; -const S_MUX = 8; +const S_TRANSLATE = 8; +const S_MUX = 9; function makeSteps(): JobStep[] { return [ @@ -59,6 +61,7 @@ function makeSteps(): JobStep[] { { label: "Zones", status: "pending", progress: 0 }, { label: "Final Encode", status: "pending", progress: 0 }, { label: "Audio", status: "pending", progress: 0 }, + { label: "Translate", status: "pending", progress: 0 }, { label: "Mux & Finish", status: "pending", progress: 0 }, ]; } @@ -1238,6 +1241,87 @@ export async function encodeJob( setStep(S_AUDIO, { status: "done", progress: 100 }); } + let subtitleStreams: SubtitleStreamInfo[] = []; + if (!skipSubtitleProcessing) { + if (opts.precomputed) { + subtitleStreams = opts.precomputed.subtitleStreams; + } else { + const allSubtitleStreams = probe.subtitleStreams || []; + await analyzeSubtitleStreams( + allSubtitleStreams, + job.inputPath, + tempDir, + { + langDetect: job.settings.subtitleLangDetect, + langDetectConfidence: job.settings.subtitleLangDetectConfidence, + detectSignsSongs: job.settings.detectSignsSongs, + detectSDH: job.settings.detectSDH, + detectHonorifics: job.settings.detectHonorifics, + signsSongsStyleRatio: job.settings.signsSongsStyleRatio, + signsSongsLineRatio: job.settings.signsSongsLineRatio, + sdhRatioThreshold: job.settings.sdhRatioThreshold, + sdhMinLines: job.settings.sdhMinLines, + honorificsMinCount: job.settings.honorificsMinCount, + honorificsRatio: job.settings.honorificsRatio, + assumeMislabeled: job.settings.assumeMislabeledTracks, + }, + signal, + ); + + const sortedSubtitleStreams = sortSubtitleStreams(allSubtitleStreams, { + sourcePriority: job.settings.subtitleSourcePriority, + fansubTiebreak: job.settings.subtitleFansubTiebreak, + formatPriority: job.settings.subtitleFormatPriority, + languagePriority: job.settings.subtitleLanguagePriority, + }); + const allowedSubLangs = job.settings.subtitleLanguages || []; + const langFilteredSubs = filterStreamsByLanguage(sortedSubtitleStreams, allowedSubLangs, "subtitle"); + const typeFilteredSubs = filterSubtitleTypes(langFilteredSubs, { + removeSDH: job.settings.removeSDHSubtitles, + removeCommentary: job.settings.removeCommentarySubtitles, + removeForcedSignsSongs: job.settings.removeForcedSignsSongs, + removeStoryboard: job.settings.removeStoryboardSubtitles, + removeHonorifics: job.settings.removeHonorificsSubtitles, + dropPicture: job.settings.dropPictureSubtitles, + }); + subtitleStreams = job.settings.dedupeSubtitles + ? deduplicateSubtitleStreams(typeFilteredSubs, { acrossFormat: job.settings.dedupeAcrossFormat }) + : typeFilteredSubs; + } + } + + checkCancelled(); + setStep(S_TRANSLATE, { status: "active", progress: 0 }); + let translatedTracks: TranslatedTrack[] = []; + if (!skipSubtitleProcessing && job.settings.translateSubtitles && !previewMode && subtitleStreams.length > 0) { + try { + translatedTracks = await runTranslateStep({ + subtitleStreams, + inputPath: job.inputPath, + tempDir, + settings: job.settings, + subtitleStyle: { ...DEFAULT_STYLE_APPEARANCE, fontName: job.settings.fontGroup }, + organization: config.organization, + signal, + onProgress: ({ lang, langIndex, langCount, done, total }) => { + const frac = total > 0 ? done / total : 1; + const overall = Math.round(((langIndex + frac) / Math.max(1, langCount)) * 100); + setStep(S_TRANSLATE, { progress: overall, detail: `Translating ${lang}: ${done}/${total}` }); + }, + }); + setStep(S_TRANSLATE, { + status: "done", + progress: 100, + detail: translatedTracks.length ? `Added ${translatedTracks.length} track(s)` : "Nothing to translate", + }); + } catch (err) { + setStep(S_TRANSLATE, { status: "error", progress: 100 }); + throw err; // fail the job with a clear message (Ollama unreachable / model missing) + } + } else { + setStep(S_TRANSLATE, { status: "done", progress: 100, detail: "Skipped" }); + } + checkCancelled(); setStep(S_MUX, { status: "active", progress: 0, detail: "Merging MKV" }); updateJob({ status: "muxing" }); @@ -1394,7 +1478,6 @@ export async function encodeJob( if (!skipSubtitleProcessing) { // Subtitle tracks - let subtitleStreams: SubtitleStreamInfo[]; if (opts.precomputed) { // Preview: reuse the whole-source subtitle selection subtitleStreams = opts.precomputed.subtitleStreams; @@ -1712,16 +1795,34 @@ export async function encodeJob( } } - for (const planned of plannedSubs) { - checkCancelled(); - if (!existsSync(planned.subFile)) { - Logger.warn(`[subtitle] Extracted file missing for track ${planned.stream.index}, skipping`); + const orderedSubs = orderOutputSubtitles( + plannedSubs.map((p) => ({ + stream: p.stream, + emit: { language: p.effectiveLang, trackName: p.trackName, flagArgs: p.flagArgs, file: p.subFile }, + })), + translatedTracks.map((t) => ({ + sourceIndex: t.sourceIndex, + emit: { language: t.language, trackName: t.trackName, flagArgs: t.flagArgs, file: t.file }, + })), + subtitleStreams, + (streams) => + sortSubtitleStreams(streams, { + sourcePriority: job.settings.subtitleSourcePriority, + fansubTiebreak: job.settings.subtitleFansubTiebreak, + formatPriority: job.settings.subtitleFormatPriority, + languagePriority: job.settings.subtitleLanguagePriority, + }), + ); + + for (const e of orderedSubs) { + if (!existsSync(e.file)) { + Logger.warn(`[subtitle] Output file missing, skipping: ${e.file}`); continue; } - mkvArgs.push("--language", `0:${sanitizeLanguageTag(planned.effectiveLang, `sub idx ${planned.stream.index}`)}`); - mkvArgs.push("--track-name", `0:${planned.trackName}`); - mkvArgs.push(...planned.flagArgs); - mkvArgs.push(planned.subFile); + mkvArgs.push("--language", `0:${sanitizeLanguageTag(e.language, `sub ${e.language}`)}`); + mkvArgs.push("--track-name", `0:${e.trackName}`); + mkvArgs.push(...e.flagArgs); + mkvArgs.push(e.file); } } else { Logger.info("[subtitle] No subtitle streams found"); diff --git a/src/index.ts b/src/index.ts index 53cb745..bbc249f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,8 @@ import { getSystemStats } from "./system"; import { fontRegistry } from "./fonts"; import type { GroupStyleConfig } from "./subtitle-style"; import { isInsideRoots, listSystemFonts } from "./system-fonts"; +import { checkOllama, translateOne } from "./ollama"; +import { resolveTranslateLang } from "./translate-languages"; export const config = await loadConfig(); @@ -385,6 +387,31 @@ app.post("/api/config/import-code", async (c) => { } }); +app.post("/api/translate/test", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as { url?: string; model?: string; target?: string }; + const url = (body.url || "").trim(); + const model = (body.model || "").trim(); + if (!url || !model) return c.json({ ok: false, error: "Missing Ollama URL or model" }, 400); + + const health = await checkOllama(url, model); + if (!health.ok) return c.json({ ok: false, error: health.detail }); + + // Prove the model actually generates: translate a tiny sample. + const target = resolveTranslateLang(body.target || "slv") ?? { name: "Slovenian", code: "sl" }; + try { + const sample = await translateOne("The goal of all life is death.", { + url, + model, + source: { name: "English", code: "en" }, + target, + timeoutMs: 30000, + }); + return c.json({ ok: true, sample, model, target: target.name }); + } catch (err: any) { + return c.json({ ok: false, error: `Model reachable but translation failed: ${err?.message || err}` }); + } +}); + app.get("/api/fonts", (c) => { return c.json({ fonts: fontRegistry.list().map((f) => ({ diff --git a/src/ollama.ts b/src/ollama.ts new file mode 100644 index 0000000..f938eb5 --- /dev/null +++ b/src/ollama.ts @@ -0,0 +1,170 @@ +import { Logger } from "./logger"; +import type { TranslateLang } from "./translate-languages"; + +/** + * Thin Ollama client specialised for TranslateGemma. TranslateGemma is a + * translation-only, single-direction model: one source->target pair per call, + * and it emits only the translation with no commentary. That makes multi-line + * batches fragile (the model can merge/drop/reorder lines), so `translateBatch` + * verifies the returned line count and falls back to per-line translation for + * any batch that doesn't align exactly. Correctness over speed. + */ + +export interface OllamaOptions { + /** Base URL "http://localhost:11434". */ + url: string; + /** Model tag "translategemma:12b". */ + model: string; + source: TranslateLang; + target: TranslateLang; + /** Ollama num_ctx. TranslateGemma supports up to 128K. */ + numCtx?: number; + /** Sampling temperature (low is best for deterministic translation) */ + temperature?: number; + /** Per-request timeout in ms. */ + timeoutMs?: number; + /** External cancellation (job abort). */ + signal?: AbortSignal; +} + +const DEFAULT_TIMEOUT_MS = 120_000; +const DEFAULT_NUM_CTX = 8192; +const DEFAULT_TEMPERATURE = 0.1; + +/** Build the TranslateGemma prompt for a block of one or more lines. */ +export function buildTranslatePrompt(source: TranslateLang, target: TranslateLang, block: string): string { + return ( + `You are a professional ${source.name} (${source.code}) to ${target.name} (${target.code}) translator. ` + + `Your goal is to accurately convey the meaning and nuances of the original ${source.name} text ` + + `while adhering to ${target.name} grammar, vocabulary, and cultural sensitivities.\n` + + `Produce only the ${target.name} translation, without any additional explanations or commentary. ` + + `Please translate the following ${source.name} text into ${target.name}:\n\n\n${block}` + ); +} + +interface OllamaChatResponse { + message?: { content?: string }; + error?: string; +} + +/** One /api/chat round-trip. Throws on HTTP or network error. */ +async function chat(prompt: string, opts: OllamaOptions): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(new Error("Ollama request timed out")), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); + + const onExternalAbort = () => controller.abort(opts.signal?.reason ?? new Error("aborted")); + if (opts.signal) { + if (opts.signal.aborted) controller.abort(opts.signal.reason); + else opts.signal.addEventListener("abort", onExternalAbort, { once: true }); + } + + try { + const base = opts.url.replace(/\/+$/, ""); + const res = await fetch(`${base}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + body: JSON.stringify({ + model: opts.model, + stream: false, + messages: [{ role: "user", content: prompt }], + options: { + num_ctx: opts.numCtx ?? DEFAULT_NUM_CTX, + temperature: opts.temperature ?? DEFAULT_TEMPERATURE, + }, + }), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Ollama HTTP ${res.status}: ${body.slice(0, 300)}`); + } + + const data = (await res.json()) as OllamaChatResponse; + if (data.error) throw new Error(`Ollama error: ${data.error}`); + return (data.message?.content ?? "").trim(); + } finally { + clearTimeout(timeout); + if (opts.signal) opts.signal.removeEventListener("abort", onExternalAbort); + } +} + +/** Translate a single line/string. */ +export async function translateOne(text: string, opts: OllamaOptions): Promise { + if (text.trim() === "") return text; + const prompt = buildTranslatePrompt(opts.source, opts.target, text); + return chat(prompt, opts); +} + +/** Split a model response into lines, tolerating a single trailing blank. */ +function splitResponseLines(content: string): string[] { + const lines = content.replace(/\r\n/g, "\n").split("\n"); + while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop(); + return lines; +} + +/** + * Translate a batch of lines in one request, returning exactly `lines.length` + * results. If the response doesn't line up 1:1, fall back to translating each + * line on its own so cue alignment is never lost. + * + * Empty input lines are passed through untouched and don't cost a request. + */ +export async function translateBatch(lines: string[], opts: OllamaOptions): Promise { + if (lines.length === 0) return []; + + // Indices that actually need translation. + const payloadIdx: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (lines[i]!.trim() !== "") payloadIdx.push(i); + } + if (payloadIdx.length === 0) return [...lines]; + + const payload = payloadIdx.map((i) => lines[i]!); + const result = [...lines]; + + if (payload.length === 1) { + result[payloadIdx[0]!] = await translateOne(payload[0]!, opts); + return result; + } + + const block = payload.join("\n"); + const prompt = buildTranslatePrompt(opts.source, opts.target, block); + const content = await chat(prompt, opts); + const outLines = splitResponseLines(content); + + if (outLines.length === payload.length) { + payloadIdx.forEach((origIdx, k) => (result[origIdx] = outLines[k]!)); + return result; + } + + // Misaligned - the model merged/split lines. Redo this batch line-by-line. + Logger.warn( + `[translate] Batch of ${payload.length} lines returned ${outLines.length} lines (${opts.source.code}->${opts.target.code}); ` + + `falling back to per-line translation for this chunk`, + ); + for (let k = 0; k < payload.length; k++) { + result[payloadIdx[k]!] = await translateOne(payload[k]!, opts); + } + return result; +} + +/** Probe Ollama for reachability and whether the model is available. */ +export async function checkOllama(url: string, model: string, signal?: AbortSignal): Promise<{ ok: boolean; detail: string }> { + try { + const base = url.replace(/\/+$/, ""); + const res = await fetch(`${base}/api/tags`, { signal }); + if (!res.ok) return { ok: false, detail: `Ollama at ${base} returned HTTP ${res.status}` }; + const data = (await res.json()) as { models?: Array<{ name?: string; model?: string }> }; + const names = (data.models ?? []).map((m) => m.name ?? m.model ?? ""); + // Match with or without an explicit tag (":latest"). + const wanted = model.includes(":") ? model : `${model}:latest`; + const present = names.some((n) => n === model || n === wanted || n.split(":")[0] === model.split(":")[0]); + if (!present) { + return { ok: false, detail: `Model "${model}" not found in Ollama. Pull it with: ollama pull ${model}` }; + } + return { ok: true, detail: "" }; + } catch (err) { + return { ok: false, detail: `Cannot reach Ollama at ${url}: ${(err as Error).message}` }; + } +} diff --git a/src/settings-code.ts b/src/settings-code.ts index bb4786c..ed6cbbd 100644 --- a/src/settings-code.ts +++ b/src/settings-code.ts @@ -114,6 +114,14 @@ const BASELINE: JobSettings = { "7.1": 384, "7.1.4": 512, }, + translateSubtitles: false, + translateOllamaUrl: "http://localhost:11434", + translateModel: "translategemma:12b", + translateTargetLanguages: [], + translateBatchSize: 40, + translateSignsSongs: true, + translateNumCtx: 8192, + translateTimeoutMs: 120000, vsFilters: [], }; diff --git a/src/srt-edit.ts b/src/srt-edit.ts new file mode 100644 index 0000000..6b1cd2d --- /dev/null +++ b/src/srt-edit.ts @@ -0,0 +1,86 @@ +/** + * Minimal, lossless SRT reader/writer for translation. Parses cues into + * `{ index, startMs, endMs, timingLine, text }`, letting the translator swap + * only the text while every cue's numbering and timing line is preserved + * verbatim on rebuild. + * + * Timing is exposed as milliseconds so the pause-aware chunker can find the + * largest gap between consecutive cues. + */ + +export interface SrtCue { + /** 1-based cue number as written (kept for rebuild). */ + index: string; + /** Verbatim timing line, e.g. "00:00:01,000 --> 00:00:03,000". */ + timingLine: string; + /** Start time in ms (parsed from timingLine). */ + startMs: number; + /** End time in ms (parsed from timingLine). */ + endMs: number; + /** Visible text, internal line breaks preserved as "\n". */ + text: string; +} + +const TIMING_RE = /(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})/; + +function tcToMs(h: string, m: string, s: string, ms: string): number { + return parseInt(h, 10) * 3_600_000 + parseInt(m, 10) * 60_000 + parseInt(s, 10) * 1_000 + parseInt(ms.padEnd(3, "0"), 10); +} + +/** Parse SRT text into cues. Non-conforming trailing blocks are skipped. */ +export function parseSrt(srt: string): SrtCue[] { + const text = srt.replace(/^\uFEFF/, ""); + // Blocks are separated by one or more blank lines. + const blocks = text.split(/\r?\n\r?\n+/); + const cues: SrtCue[] = []; + + for (const block of blocks) { + const lines = block.split(/\r?\n/); + // Trim leading and trailing blank lines within the block. + while (lines.length && lines[0]!.trim() === "") lines.shift(); + while (lines.length && lines[lines.length - 1]!.trim() === "") lines.pop(); + if (lines.length === 0) continue; + + let idx = 0; + let indexLabel = ""; + // An index line is optional but usual; detect it by "not a timing line". + if (!TIMING_RE.test(lines[0]!) && lines[1] && TIMING_RE.test(lines[1]!)) { + indexLabel = lines[0]!.trim(); + idx = 1; + } + + const timingLine = lines[idx]; + if (!timingLine) continue; + const m = timingLine.match(TIMING_RE); + if (!m) continue; + + const startMs = tcToMs(m[1]!, m[2]!, m[3]!, m[4]!); + const endMs = tcToMs(m[5]!, m[6]!, m[7]!, m[8]!); + const body = lines.slice(idx + 1).join("\n"); + + cues.push({ + index: indexLabel || String(cues.length + 1), + timingLine: timingLine.trim(), + startMs, + endMs, + text: body, + }); + } + + return cues; +} + +/** + * Rebuild an SRT document from cues. Cue numbering is renumbered 1..N so the + * output is always well-formed even if the source omitted or skipped numbers. + */ +export function buildSrt(cues: SrtCue[]): string { + const out: string[] = []; + cues.forEach((cue, i) => { + out.push(String(i + 1)); + out.push(cue.timingLine); + out.push(cue.text); + out.push(""); + }); + return out.join("\n"); +} diff --git a/src/store.ts b/src/store.ts index fe4770f..55d9454 100644 --- a/src/store.ts +++ b/src/store.ts @@ -263,6 +263,15 @@ const SETTINGS_SANITIZERS: { [K in keyof JobSettings]?: Sanitizer } = { removeUnusedFonts: bool, assRestyleTargets: strList, fontGroup: str(256), + + translateSubtitles: bool, + translateOllamaUrl: str(512), + translateModel: str(128), + translateTargetLanguages: strList, + translateBatchSize: intIn(1, 1000), + translateSignsSongs: bool, + translateNumCtx: intIn(512, 131072), + translateTimeoutMs: intIn(1000, 3600000), }; function sanitizeSettingsInto(target: JobSettings, partial: Partial): void { diff --git a/src/subtitle-translate.ts b/src/subtitle-translate.ts new file mode 100644 index 0000000..082101f --- /dev/null +++ b/src/subtitle-translate.ts @@ -0,0 +1,294 @@ +import { Logger } from "./logger"; +import { parseAssEvents, splitAssText, joinAssText, buildTranslatedAss, type AssEventLine } from "./ass-edit"; +import { parseSrt, buildSrt } from "./srt-edit"; +import { translateBatch, type OllamaOptions } from "./ollama"; +import { resolveTranslateLang, normalizeTag, type TranslateLang } from "./translate-languages"; + +/** + * Split a timed line sequence into translation chunks of roughly `batchSize`, + * nudging each boundary to the largest pause nearby so a chunk never cuts + * mid-conversation. + * + * Algorithm (matches the agreed spec): window = round(batchSize * 0.2). At each + * nominal boundary `n = i + batchSize`, consider split points b in + * [n - window, n + window] and pick the one with the largest gap between the + * previous line's end and the next line's start. The final short chunk takes + * the remainder with no search. + * + * Returns half-open [start, end) index ranges covering [0, count). + */ +export function planChunks(startsMs: number[], endsMs: number[], batchSize: number): Array<[number, number]> { + const n = startsMs.length; + const chunks: Array<[number, number]> = []; + if (n === 0) return chunks; + + const size = Math.max(1, Math.floor(batchSize)); + const window = Math.round(size * 0.2); + + let i = 0; + while (i < n) { + const nominalEnd = i + size; // exclusive + if (nominalEnd >= n) { + chunks.push([i, n]); + break; + } + if (window <= 0) { + chunks.push([i, nominalEnd]); + i = nominalEnd; + continue; + } + + const lo = Math.max(i + 1, nominalEnd - window); + const hi = Math.min(n, nominalEnd + window); // b may equal n (split at end) + + let bestB = nominalEnd; + let bestGap = Number.NEGATIVE_INFINITY; + for (let b = lo; b <= hi; b++) { + if (b <= i) continue; + // Gap that would be "opened" by cutting before line b. + const gap = b >= n ? Number.POSITIVE_INFINITY : startsMs[b]! - endsMs[b - 1]!; + if (gap > bestGap) { + bestGap = gap; + bestB = b; + } + } + chunks.push([i, bestB]); + i = bestB; + } + + return chunks; +} + +export interface TranslateContentOptions { + format: "ass" | "srt"; + batchSize: number; + /** When false, only dialogue-classified ASS lines are translated. */ + translateSignsSongs: boolean; + /** + * ASS-only: predicate telling whether a style name is dialogue. Required when + * `translateSignsSongs` is false; ignored for SRT. Supply via ass-classifier's + * `dialogueStyleNames` in production. + */ + isDialogueStyle?: (style: string) => boolean; + ollama: OllamaOptions; + /** Reports cumulative translated-line progress. */ + onProgress?: (done: number, total: number) => void; +} + +interface Unit { + /** For ASS: event line number. For SRT: cue index. */ + key: number; + startMs: number; + endMs: number; + /** Text handed to the model. */ + visible: string; + /** ASS leading override block to re-prepend, or "". */ + lead: string; +} + +/** + * Translate the contents of one subtitle file (ASS or SRT), returning the new + * file contents. Structure, timing, styling, and non-translated lines are + * preserved; only visible dialogue (and, if enabled, signs/songs) is replaced. + */ +export async function translateSubtitleContent(content: string, opts: TranslateContentOptions): Promise { + if (opts.format === "ass") return translateAss(content, opts); + return translateSrt(content, opts); +} + +async function translateUnits(units: Unit[], opts: TranslateContentOptions): Promise> { + const out = new Map(); + if (units.length === 0) return out; + + const starts = units.map((u) => u.startMs); + const ends = units.map((u) => u.endMs); + const chunks = planChunks(starts, ends, opts.batchSize); + + let done = 0; + const total = units.length; + opts.onProgress?.(0, total); + + for (const [lo, hi] of chunks) { + const slice = units.slice(lo, hi); + const visibles = slice.map((u) => u.visible); + const translated = await translateBatch(visibles, opts.ollama); + for (let k = 0; k < slice.length; k++) { + out.set(slice[k]!.key, translated[k]!); + } + done += slice.length; + opts.onProgress?.(done, total); + } + + return out; +} + +async function translateAss(content: string, opts: TranslateContentOptions): Promise { + const { events } = parseAssEvents(content); + + const dialogueOnly = !opts.translateSignsSongs; + const isDialogue = opts.isDialogueStyle ?? (() => true); + + const units: Unit[] = []; + for (const ev of events) { + if (dialogueOnly && !isDialogue(ev.style)) continue; + const parts = splitAssText(ev.rawText); + if (!parts.translatable) continue; + units.push({ key: ev.lineNo, startMs: ev.startMs, endMs: ev.endMs, visible: parts.visible, lead: parts.lead }); + } + + if (units.length === 0) { + Logger.info("[translate] ASS source has no translatable lines; emitting a copy"); + return content; + } + + const leadByKey = new Map(); + for (const u of units) leadByKey.set(u.key, u.lead); + + const translatedVisible = await translateUnits(units, opts); + + const newTextByLineNo = new Map(); + for (const [key, visible] of translatedVisible) { + newTextByLineNo.set(key, joinAssText(leadByKey.get(key) ?? "", visible)); + } + + return buildTranslatedAss(content, newTextByLineNo, events as AssEventLine[]); +} + +async function translateSrt(content: string, opts: TranslateContentOptions): Promise { + const cues = parseSrt(content); + + const units: Unit[] = []; + cues.forEach((cue, i) => { + // Flatten internal breaks so each cue is a single batch line; players + // re-wrap. (ASS preserves layout; SRT trades wrapping for reliable + // batch alignment.) + const visible = cue.text.replace(/\r?\n/g, " ").trim(); + if (visible === "") return; + units.push({ key: i, startMs: cue.startMs, endMs: cue.endMs, visible, lead: "" }); + }); + + if (units.length === 0) return content; + + const translated = await translateUnits(units, opts); + for (const [i, text] of translated) { + cues[i]!.text = text; + } + + return buildSrt(cues); +} + +// Target-language planning + +export interface KeptSubDescriptor { + index: number; + codec: string; + /** Effective language tag (honorifics already mapped to its base, e.g. "en-JP"). */ + language: string; + /** full | honorifics | forced | sdh | commentary | storyboard */ + trackType: string; +} + +export interface TranslationProduction { + /** Source track to translate from. */ + sourceIndex: number; + sourceCodec: string; + source: TranslateLang; + /** Output MKV language tag (as requested by the user, normalized). */ + targetTag: string; + target: TranslateLang; + /** Mirror the source's dialogue role. */ + trackType: "full" | "honorifics"; +} + +export interface TranslationPlan { + productions: TranslationProduction[]; + /** Human-readable reasons for anything skipped (for logging). */ + skipped: string[]; +} + +const TEXT_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "mov_text", "text", "subviewer", "microdvd"]); + +/** Canonical language key for "does a full track in this language already exist". */ +function langKey(tag: string | undefined): string { + const t = resolveTranslateLang(tag); + if (t) return t.code; + return normalizeTag(tag).split(/[-_]/)[0] || "und"; +} + +/** + * Decide which target languages to produce and from which source track. + * + * Rules: + * - Source = the first text-based `full` or `honorifics` track. + * - A target language is skipped if a `full` or `honorifics` track already + * exists for it (either counts), or if it equals the source language. + * - Untranslatable languages (outside the model's set) are skipped with a note. + * - The produced track mirrors the source role (honorifics source -> honorifics + * output; otherwise full). + */ +export function planTargetLanguages(tracks: KeptSubDescriptor[], targetTags: string[]): TranslationPlan { + const skipped: string[] = []; + const productions: TranslationProduction[] = []; + + // Languages already covered by a dialogue-bearing full/honorifics track. + const existing = new Set(); + + for (const track of tracks) { + if (track.trackType === "full" || track.trackType === "honorifics") { + existing.add(langKey(track.language)); + } + } + + // Select the first eligible dialogue-bearing text track. + const source = tracks.find((track) => (track.trackType === "full" || track.trackType === "honorifics") && TEXT_CODECS.has(track.codec.toLowerCase())); + + if (!source) { + skipped.push("no text-based full or honorifics subtitle track available to translate from"); + return { productions, skipped }; + } + + const sourceLang = resolveTranslateLang(source.language); + + if (!sourceLang) { + skipped.push(`source track language "${source.language}" is not supported by the model`); + return { productions, skipped }; + } + + const sourceKey = langKey(source.language); + const seen = new Set(); + + for (const rawTag of targetTags) { + const tag = rawTag.trim(); + if (!tag) continue; + + const target = resolveTranslateLang(tag); + + if (!target) { + skipped.push(`${tag}: not a language the model supports`); + continue; + } + + const key = target.code; + + if (key === sourceKey) continue; + + if (existing.has(key)) { + skipped.push(`${tag}: a full/honorifics track already exists`); + continue; + } + + if (seen.has(key)) continue; + seen.add(key); + + productions.push({ + sourceIndex: source.index, + sourceCodec: source.codec, + source: sourceLang, + targetTag: normalizeTag(tag), + target, + trackType: source.trackType as "full" | "honorifics", + }); + } + + return { productions, skipped }; +} diff --git a/src/tracks.ts b/src/tracks.ts index 91b9b05..d74c671 100644 --- a/src/tracks.ts +++ b/src/tracks.ts @@ -962,7 +962,7 @@ export function buildAudioTrackName(trackType: AudioTrackType, sourceTitle?: str * "Signs & Songs" * "Full Subtitles [MTBB]" */ -export function buildSubtitleTrackName(trackType: SubtitleTrackType, sourceTitle?: string): string { +export function buildSubtitleTrackName(trackType: SubtitleTrackType, sourceTitle?: string, groupOverride?: string): string { const title = sourceTitle || ""; const isDubtitle = /dubtitle|\bdub\b/i.test(title); @@ -974,9 +974,10 @@ export function buildSubtitleTrackName(trackType: SubtitleTrackType, sourceTitle commentary: "Commentary", storyboard: "Storyboards", }; - let label = labels[trackType]; + if (groupOverride) return `${label} [${groupOverride}]`; + const source = extractSourceTag(title); if (source) return `${label} [${source}]`; diff --git a/src/translate-languages.ts b/src/translate-languages.ts new file mode 100644 index 0000000..7d983c3 --- /dev/null +++ b/src/translate-languages.ts @@ -0,0 +1,476 @@ +import { LANG_ALIASES } from "./naming"; + +/** + * A language TranslateGemma can translate to/from, described the way its prompt + * template expects it: a human-readable `name` and a BCP-47-ish `code` + * (for example "en", "pt-BR", or "zh-Hant"). + */ +export interface TranslateLang { + /** Human-readable language name used in the TranslateGemma prompt. */ + name: string; + /** Language code used in the TranslateGemma prompt. */ + code: string; +} + +/** + * Base languages exposed by the supported-language table on Ollama's + * TranslateGemma page. The model headline mentions 55 benchmarked languages, + * while the table/template exposes 161 base languages; languages outside the + * benchmarked set should be treated as potentially lower quality. + * + * Regional and script subtags are retained by `resolveTranslateLang`, so + * inputs such as "pt-BR", "sr-Latn", and "es-419" keep their requested + * variant while still being validated against this base-language list. + */ +const TRANSLATE_LANGS: Record = { + aa: { name: "Afar", code: "aa" }, + ab: { name: "Abkhazian", code: "ab" }, + af: { name: "Afrikaans", code: "af" }, + ak: { name: "Akan", code: "ak" }, + am: { name: "Amharic", code: "am" }, + an: { name: "Aragonese", code: "an" }, + ar: { name: "Arabic", code: "ar" }, + as: { name: "Assamese", code: "as" }, + az: { name: "Azerbaijani", code: "az" }, + ba: { name: "Bashkir", code: "ba" }, + be: { name: "Belarusian", code: "be" }, + bg: { name: "Bulgarian", code: "bg" }, + bm: { name: "Bambara", code: "bm" }, + bn: { name: "Bengali", code: "bn" }, + bo: { name: "Tibetan", code: "bo" }, + br: { name: "Breton", code: "br" }, + bs: { name: "Bosnian", code: "bs" }, + ca: { name: "Catalan", code: "ca" }, + ce: { name: "Chechen", code: "ce" }, + co: { name: "Corsican", code: "co" }, + cs: { name: "Czech", code: "cs" }, + cv: { name: "Chuvash", code: "cv" }, + cy: { name: "Welsh", code: "cy" }, + da: { name: "Danish", code: "da" }, + de: { name: "German", code: "de" }, + dv: { name: "Divehi", code: "dv" }, + dz: { name: "Dzongkha", code: "dz" }, + ee: { name: "Ewe", code: "ee" }, + el: { name: "Greek", code: "el" }, + en: { name: "English", code: "en" }, + eo: { name: "Esperanto", code: "eo" }, + es: { name: "Spanish", code: "es" }, + et: { name: "Estonian", code: "et" }, + eu: { name: "Basque", code: "eu" }, + fa: { name: "Persian", code: "fa" }, + ff: { name: "Fulah", code: "ff" }, + fi: { name: "Finnish", code: "fi" }, + fil: { name: "Filipino", code: "fil-PH" }, + fo: { name: "Faroese", code: "fo" }, + fr: { name: "French", code: "fr" }, + fy: { name: "Western Frisian", code: "fy" }, + ga: { name: "Irish", code: "ga" }, + gd: { name: "Scottish Gaelic", code: "gd" }, + gl: { name: "Galician", code: "gl" }, + gn: { name: "Guarani", code: "gn" }, + gu: { name: "Gujarati", code: "gu" }, + gv: { name: "Manx", code: "gv" }, + ha: { name: "Hausa", code: "ha" }, + he: { name: "Hebrew", code: "he" }, + hi: { name: "Hindi", code: "hi" }, + hr: { name: "Croatian", code: "hr" }, + ht: { name: "Haitian", code: "ht" }, + hu: { name: "Hungarian", code: "hu" }, + hy: { name: "Armenian", code: "hy" }, + ia: { name: "Interlingua", code: "ia" }, + id: { name: "Indonesian", code: "id" }, + ie: { name: "Interlingue", code: "ie" }, + ig: { name: "Igbo", code: "ig" }, + ii: { name: "Sichuan Yi", code: "ii" }, + ik: { name: "Inupiaq", code: "ik" }, + io: { name: "Ido", code: "io" }, + is: { name: "Icelandic", code: "is" }, + it: { name: "Italian", code: "it" }, + iu: { name: "Inuktitut", code: "iu" }, + ja: { name: "Japanese", code: "ja" }, + jv: { name: "Javanese", code: "jv" }, + ka: { name: "Georgian", code: "ka" }, + ki: { name: "Kikuyu", code: "ki" }, + kk: { name: "Kazakh", code: "kk" }, + kl: { name: "Kalaallisut", code: "kl" }, + km: { name: "Central Khmer", code: "km" }, + kn: { name: "Kannada", code: "kn" }, + ko: { name: "Korean", code: "ko" }, + ks: { name: "Kashmiri", code: "ks" }, + ku: { name: "Kurdish", code: "ku" }, + kw: { name: "Cornish", code: "kw" }, + ky: { name: "Kyrgyz", code: "ky" }, + la: { name: "Latin", code: "la" }, + lb: { name: "Luxembourgish", code: "lb" }, + lg: { name: "Ganda", code: "lg" }, + ln: { name: "Lingala", code: "ln" }, + lo: { name: "Lao", code: "lo" }, + lt: { name: "Lithuanian", code: "lt" }, + lu: { name: "Luba-Katanga", code: "lu" }, + lv: { name: "Latvian", code: "lv" }, + mg: { name: "Malagasy", code: "mg" }, + mi: { name: "Maori", code: "mi" }, + mk: { name: "Macedonian", code: "mk" }, + ml: { name: "Malayalam", code: "ml" }, + mn: { name: "Mongolian", code: "mn" }, + mr: { name: "Marathi", code: "mr" }, + ms: { name: "Malay", code: "ms" }, + mt: { name: "Maltese", code: "mt" }, + my: { name: "Burmese", code: "my" }, + nb: { name: "Norwegian Bokmål", code: "nb" }, + nd: { name: "North Ndebele", code: "nd" }, + ne: { name: "Nepali", code: "ne" }, + nl: { name: "Dutch", code: "nl" }, + nn: { name: "Norwegian Nynorsk", code: "nn" }, + no: { name: "Norwegian", code: "no" }, + nr: { name: "South Ndebele", code: "nr" }, + nv: { name: "Navajo", code: "nv" }, + ny: { name: "Chichewa", code: "ny" }, + oc: { name: "Occitan", code: "oc" }, + om: { name: "Oromo", code: "om" }, + or: { name: "Oriya", code: "or" }, + os: { name: "Ossetian", code: "os" }, + pa: { name: "Punjabi", code: "pa" }, + pl: { name: "Polish", code: "pl" }, + ps: { name: "Pashto", code: "ps" }, + pt: { name: "Portuguese", code: "pt" }, + qu: { name: "Quechua", code: "qu" }, + rm: { name: "Romansh", code: "rm" }, + rn: { name: "Rundi", code: "rn" }, + ro: { name: "Romanian", code: "ro" }, + ru: { name: "Russian", code: "ru" }, + rw: { name: "Kinyarwanda", code: "rw" }, + sa: { name: "Sanskrit", code: "sa" }, + sc: { name: "Sardinian", code: "sc" }, + sd: { name: "Sindhi", code: "sd" }, + se: { name: "Northern Sami", code: "se" }, + sg: { name: "Sango", code: "sg" }, + si: { name: "Sinhala", code: "si" }, + sk: { name: "Slovak", code: "sk" }, + sl: { name: "Slovenian", code: "sl" }, + sn: { name: "Shona", code: "sn" }, + so: { name: "Somali", code: "so" }, + sq: { name: "Albanian", code: "sq" }, + sr: { name: "Serbian", code: "sr" }, + ss: { name: "Swati", code: "ss" }, + st: { name: "Southern Sotho", code: "st" }, + su: { name: "Sundanese", code: "su" }, + sv: { name: "Swedish", code: "sv" }, + sw: { name: "Swahili", code: "sw" }, + ta: { name: "Tamil", code: "ta" }, + te: { name: "Telugu", code: "te" }, + tg: { name: "Tajik", code: "tg" }, + th: { name: "Thai", code: "th" }, + ti: { name: "Tigrinya", code: "ti" }, + tk: { name: "Turkmen", code: "tk" }, + tl: { name: "Tagalog", code: "tl" }, + tn: { name: "Tswana", code: "tn" }, + to: { name: "Tonga", code: "to" }, + tr: { name: "Turkish", code: "tr" }, + ts: { name: "Tsonga", code: "ts" }, + tt: { name: "Tatar", code: "tt" }, + ug: { name: "Uyghur", code: "ug" }, + uk: { name: "Ukrainian", code: "uk" }, + ur: { name: "Urdu", code: "ur" }, + uz: { name: "Uzbek", code: "uz" }, + ve: { name: "Venda", code: "ve" }, + vi: { name: "Vietnamese", code: "vi" }, + vo: { name: "Volapük", code: "vo" }, + wa: { name: "Walloon", code: "wa" }, + wo: { name: "Wolof", code: "wo" }, + xh: { name: "Xhosa", code: "xh" }, + yi: { name: "Yiddish", code: "yi" }, + yo: { name: "Yoruba", code: "yo" }, + za: { name: "Zhuang", code: "za" }, + zh: { name: "Chinese", code: "zh" }, + zu: { name: "Zulu", code: "zu" }, +}; + +/** + * ISO-639-2/3 aliases for MKV language tags. Alpha-2 tags are resolved + * directly through `TRANSLATE_LANGS`; this table covers terminology and + * bibliographic three-letter forms plus a few common legacy aliases. + */ +const ISO639_ALIAS_TO_GGM: Record = { + aar: "aa", + abk: "ab", + afr: "af", + aka: "ak", + amh: "am", + arg: "an", + ara: "ar", + asm: "as", + aze: "az", + bak: "ba", + bel: "be", + bul: "bg", + bam: "bm", + ben: "bn", + bod: "bo", + tib: "bo", + bre: "br", + bos: "bs", + cat: "ca", + che: "ce", + cos: "co", + ces: "cs", + cze: "cs", + chv: "cv", + cym: "cy", + wel: "cy", + dan: "da", + deu: "de", + ger: "de", + div: "dv", + dzo: "dz", + ewe: "ee", + ell: "el", + gre: "el", + eng: "en", + epo: "eo", + spa: "es", + est: "et", + eus: "eu", + baq: "eu", + fas: "fa", + per: "fa", + ful: "ff", + fin: "fi", + fil: "fil", + fao: "fo", + fra: "fr", + fre: "fr", + fry: "fy", + gle: "ga", + gla: "gd", + glg: "gl", + grn: "gn", + guj: "gu", + glv: "gv", + hau: "ha", + heb: "he", + hin: "hi", + hrv: "hr", + hat: "ht", + hun: "hu", + hye: "hy", + arm: "hy", + ina: "ia", + ind: "id", + ile: "ie", + ibo: "ig", + iii: "ii", + ipk: "ik", + ido: "io", + isl: "is", + ice: "is", + ita: "it", + iku: "iu", + jpn: "ja", + jav: "jv", + kat: "ka", + geo: "ka", + kik: "ki", + kaz: "kk", + kal: "kl", + khm: "km", + kan: "kn", + kor: "ko", + kas: "ks", + kur: "ku", + cor: "kw", + kir: "ky", + lat: "la", + ltz: "lb", + lug: "lg", + lin: "ln", + lao: "lo", + lit: "lt", + lub: "lu", + lav: "lv", + mlg: "mg", + mri: "mi", + mao: "mi", + mkd: "mk", + mac: "mk", + mal: "ml", + mon: "mn", + mar: "mr", + msa: "ms", + may: "ms", + mlt: "mt", + mya: "my", + bur: "my", + nob: "nb", + nde: "nd", + nep: "ne", + nld: "nl", + dut: "nl", + nno: "nn", + nor: "no", + nbl: "nr", + nav: "nv", + nya: "ny", + oci: "oc", + orm: "om", + ori: "or", + oss: "os", + pan: "pa", + pol: "pl", + pus: "ps", + por: "pt", + que: "qu", + roh: "rm", + run: "rn", + ron: "ro", + rum: "ro", + rus: "ru", + kin: "rw", + san: "sa", + srd: "sc", + snd: "sd", + sme: "se", + sag: "sg", + sin: "si", + slk: "sk", + slo: "sk", + slv: "sl", + sna: "sn", + som: "so", + sqi: "sq", + alb: "sq", + srp: "sr", + ssw: "ss", + sot: "st", + sun: "su", + swe: "sv", + swa: "sw", + tam: "ta", + tel: "te", + tgk: "tg", + tha: "th", + tir: "ti", + tuk: "tk", + tgl: "tl", + tsn: "tn", + ton: "to", + tur: "tr", + tso: "ts", + tat: "tt", + uig: "ug", + ukr: "uk", + urd: "ur", + uzb: "uz", + ven: "ve", + vie: "vi", + vol: "vo", + wln: "wa", + wol: "wo", + xho: "xh", + yid: "yi", + yor: "yo", + zha: "za", + zho: "zh", + chi: "zh", + zul: "zu", + iw: "he", + in: "id", + ji: "yi", +}; + +/** Normalise a language tag the same way the rest of the pipeline does. */ +export function normalizeTag(input: string | undefined): string { + if (!input) return "und"; + const s = input.toLowerCase().trim(); + return LANG_ALIASES[s] || s; +} + +/** Convert a loose locale tag into conventional BCP-47 casing. */ +function canonicalizeLocale(code: string): string { + const parts = code.replace(/_/g, "-").split("-").filter(Boolean); + if (parts.length === 0) return "und"; + + return parts + .map((part, index) => { + if (index === 0) return part.toLowerCase(); + if (/^[a-z]{4}$/i.test(part)) return `${part[0]!.toUpperCase()}${part.slice(1).toLowerCase()}`; + if (/^[a-z]{2}$/i.test(part)) return part.toUpperCase(); + return part.toLowerCase(); + }) + .join("-"); +} + +/** Resolve Chinese aliases and script/region variants deliberately. */ +function resolveChinese(lower: string): TranslateLang { + if (/latn/.test(lower)) return { name: "Chinese", code: "zh-Latn" }; + + if (/hant|traditional/.test(lower)) { + if (/(?:^|[-_])hk(?:$|[-_])/.test(lower)) return { name: "Chinese", code: "zh-Hant-HK" }; + if (/(?:^|[-_])mo(?:$|[-_])/.test(lower)) return { name: "Chinese", code: "zh-Hant-MO" }; + if (/(?:^|[-_])my(?:$|[-_])/.test(lower)) return { name: "Chinese", code: "zh-Hant-MY" }; + return { name: "Chinese (Traditional)", code: "zh-Hant" }; + } + + if (/(?:^|[-_])tw(?:$|[-_])/.test(lower)) return { name: "Chinese (Traditional)", code: "zh-TW" }; + if (/(?:^|[-_])(hk|mo)(?:$|[-_])/.test(lower)) return { name: "Chinese (Traditional)", code: "zh-Hant" }; + + if (/hans|simplified/.test(lower)) { + if (/(?:^|[-_])hk(?:$|[-_])/.test(lower)) return { name: "Chinese", code: "zh-Hans-HK" }; + if (/(?:^|[-_])mo(?:$|[-_])/.test(lower)) return { name: "Chinese", code: "zh-Hans-MO" }; + if (/(?:^|[-_])my(?:$|[-_])/.test(lower)) return { name: "Chinese", code: "zh-Hans-MY" }; + if (/(?:^|[-_])sg(?:$|[-_])/.test(lower)) return { name: "Chinese", code: "zh-Hans-SG" }; + } + + return { name: "Chinese (Simplified)", code: "zh-Hans" }; +} + +/** + * Resolve an MKV/user language tag to the descriptor used in the + * TranslateGemma prompt, or `null` when its base language is unsupported. + * + * Examples: + * - "deu" / "ger" -> German (de) + * - "pt-BR" -> Portuguese (pt-BR) + * - "sr-Latn" -> Serbian (sr-Latn) + * - "zh-TW" -> Chinese (Traditional) (zh-TW) + * - honorifics pseudo-tag "en-JP" -> English (en) + */ +export function resolveTranslateLang(tag: string | undefined): TranslateLang | null { + if (!tag) return null; + + const norm = normalizeTag(tag); + const lower = norm.replace(/_/g, "-").toLowerCase(); + + // Internal honorifics pseudo-tag: the subtitle text is still English. + if (lower === "en-jp") return TRANSLATE_LANGS.en!; + + const rawParts = lower.split("-").filter(Boolean); + const rawBase = rawParts[0]; + if (!rawBase) return null; + + // Chinese needs explicit script handling before generic alias resolution. + if (rawBase === "zh" || rawBase === "zho" || rawBase === "chi") { + return resolveChinese(lower); + } + + const baseCode = TRANSLATE_LANGS[rawBase] ? rawBase : ISO639_ALIAS_TO_GGM[rawBase]; + if (!baseCode) return null; + + const base = TRANSLATE_LANGS[baseCode]; + if (!base) return null; + + // No variant was requested. Filipino is emitted as fil-PH because that is + // the code exposed by Ollama's supported-language table. + if (rawParts.length === 1) return base; + + const requestedVariant = rawParts.slice(1).join("-"); + return { + name: base.name, + code: canonicalizeLocale(`${baseCode}-${requestedVariant}`), + }; +} + +/** True when TranslateGemma accepts this language's base code. */ +export function isTranslatable(tag: string | undefined): boolean { + return resolveTranslateLang(tag) !== null; +} diff --git a/src/translate-step.ts b/src/translate-step.ts new file mode 100644 index 0000000..c7a64b2 --- /dev/null +++ b/src/translate-step.ts @@ -0,0 +1,333 @@ +import { join } from "path"; +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { Logger } from "./logger"; +import { run } from "./process"; +import type { JobSettings, SubtitleStreamInfo, SubtitleStyle } from "./types"; +import { detectSubtitleTrackType, buildSubtitleTrackName, isTextSubtitleCodec, sortSubtitleStreams } from "./tracks"; +import { dialogueStyleNames } from "./ass-classifier"; +import { styleSrtAss, restyleAssDialogueFont } from "./ass-style"; +import { checkOllama, type OllamaOptions } from "./ollama"; +import { planTargetLanguages, translateSubtitleContent, type KeptSubDescriptor } from "./subtitle-translate"; + +/** + * A finished, on-disk translated subtitle ready to hand to mkvmerge. Shaped to + * slot straight into the encoder's existing subtitle-append loop. + */ +export interface TranslatedTrack { + /** Path to the translated (and, if applicable, styled) .ass/.srt file. */ + file: string; + /** MKV language tag for --language (already normalized; caller sanitizes). */ + language: string; + /** --track-name value (carries the organization as release group). */ + trackName: string; + trackType: "full" | "honorifics"; + /** mkvmerge flag args (default/forced/etc.) for this track. */ + flagArgs: string[]; + /** "ass" | "srt" - for logging/inspection. */ + format: "ass" | "srt"; + /** Source subtitle stream index this was translated from (for output ordering). */ + sourceIndex: number; +} + +export interface TranslateProgress { + lang: string; + langIndex: number; + langCount: number; + done: number; + total: number; +} + +export interface RunTranslateStepParams { + /** Final selected subtitle list (sorted/filtered/deduped) - same list Mux uses. */ + subtitleStreams: SubtitleStreamInfo[]; + inputPath: string; + tempDir: string; + settings: JobSettings; + /** + * The subtitle style (with `fontName` already set to the family Mux injects) + * used when converting SRT→ASS or restyling ASS, so the translated track + * matches the source's final design. Compose it exactly as the Mux styling + * pass does, e.g. `{ ...DEFAULT_STYLE_APPEARANCE, fontName: settings.fontGroup }`. + */ + subtitleStyle: SubtitleStyle; + /** Release-group tag to stamp on translated track names (config.organization). */ + organization: string; + signal?: AbortSignal; + onProgress?: (p: TranslateProgress) => void; +} + +/** Flag args for a freshly-created translated track (always the only track in its language). */ +export function computeTranslatedFlagArgs(trackType: "full" | "honorifics"): string[] { + // Both roles are dialogue-bearing and become the default for their (new) language. + return [ + "--default-track-flag", + "0:1", + "--forced-display-flag", + "0:0", + "--hearing-impaired-flag", + "0:0", + "--commentary-flag", + "0:0", + "--original-flag", + "0:0", + ]; +} + +/** Resolve the on-disk format the translated file will have. */ +export function resolveOutputFormat(sourceCodec: string, convertSrtToAss: boolean): "ass" | "srt" { + const c = sourceCodec.toLowerCase(); + if (c === "ass" || c === "ssa") return "ass"; + return convertSrtToAss ? "ass" : "srt"; +} + +/** Build the descriptors planTargetLanguages consumes, applying the honorifics language convention. */ +export function buildKeptDescriptors( + streams: SubtitleStreamInfo[], + detectType: (s: SubtitleStreamInfo) => string = (s) => detectSubtitleTrackType(s), +): KeptSubDescriptor[] { + return streams.map((s) => { + const trackType = detectType(s); + const language = trackType === "honorifics" ? "en-JP" : s.language || "und"; + return { index: s.index, codec: s.codec, language, trackType }; + }); +} + +/** + * Extract a single subtitle track from the source to a text file. ASS is copied + * verbatim (design preserved); everything else is transcoded to SRT. + */ +async function extractSource( + inputPath: string, + stream: SubtitleStreamInfo, + tempDir: string, + signal?: AbortSignal, +): Promise<{ path: string; codec: string } | null> { + const isAss = ["ass", "ssa"].includes(stream.codec.toLowerCase()); + const ext = isAss ? "ass" : "srt"; + const out = join(tempDir, `translate_src_${stream.index}.${ext}`); + const codecArgs = isAss ? ["-c:s", "copy"] : ["-c:s", "srt"]; + const res = await run( + ["ffmpeg", "-y", "-i", inputPath, "-map", `0:${stream.index}`, ...codecArgs, "-vn", "-an", "-map_chapters", "-1", "-map_metadata", "-1", out], + { signal }, + ); + if (res.code !== 0 || !existsSync(out)) { + Logger.warn(`[translate] Failed to extract source track ${stream.index}: ${res.stderr || res.stdout}`); + return null; + } + return { path: out, codec: stream.codec }; +} + +/** + * Apply the same styling the native track would receive, so the translated + * track matches the source's final design. SRT→ASS conversion and ASS dialogue + * restyle mirror the Mux settings. Returns the styled content + final format. + * + * Font-family note: restyle injects `restyleFamily` (the family Mux attaches). + * In the rare case Mux picks a numbered alias due to a same-named source font, + * the family can differ - logged, and libass falls back gracefully. + */ +async function styleSource( + srcPath: string, + sourceCodec: string, + trackType: string, + settings: JobSettings, + subtitleStyle: SubtitleStyle, + tempDir: string, + streamIndex: number, + signal?: AbortSignal, +): Promise<{ content: string; format: "ass" | "srt" }> { + const c = sourceCodec.toLowerCase(); + const isAss = c === "ass" || c === "ssa"; + const isSrt = c === "subrip" || c === "srt"; + + if (isSrt && settings.convertSrtToAss) { + const assPath = join(tempDir, `translate_src_${streamIndex}.conv.ass`); + const r = await run(["ffmpeg", "-y", "-i", srcPath, "-map", "0:s:0", "-c:s", "ass", assPath], { signal }); + if (r.code === 0 && existsSync(assPath)) { + const styled = styleSrtAss(readFileSync(assPath, "utf-8"), subtitleStyle); + return { content: styled, format: "ass" }; + } + Logger.warn(`[translate] SRT→ASS conversion failed for source ${streamIndex}; translating as SRT`); + return { content: readFileSync(srcPath, "utf-8"), format: "srt" }; + } + + if (isAss) { + let content = readFileSync(srcPath, "utf-8"); + const targets = new Set(settings.assRestyleTargets ?? []); + if (settings.restyleAssFont && targets.has(trackType)) { + content = restyleAssDialogueFont(content, subtitleStyle, true); + } + return { content, format: "ass" }; + } + + return { content: readFileSync(srcPath, "utf-8"), format: "srt" }; +} + +/** + * The Translate pipeline step. Selects a source track (honorifics preferred, + * else the top text track), extracts + styles it once, and produces one + * translated track per missing target language. Returns finished tracks for the + * caller to append at mux time. + * + * Throws if translation is enabled but Ollama is unreachable or the model is + * missing (the user explicitly asked to translate; shipping without it silently + * would be worse). + */ +export async function runTranslateStep(params: RunTranslateStepParams): Promise { + const { subtitleStreams, inputPath, tempDir, settings, subtitleStyle, organization, signal, onProgress } = params; + + const targets = settings.translateTargetLanguages ?? []; + if (!settings.translateSubtitles || targets.length === 0) return []; + + const descriptors = buildKeptDescriptors(subtitleStreams); + const plan = planTargetLanguages(descriptors, targets); + for (const note of plan.skipped) Logger.info(`[translate] Skipped ${note}`); + if (plan.productions.length === 0) { + Logger.info("[translate] Nothing to translate (all target languages already present or unsupported)"); + return []; + } + + // Preflight: fail fast with a clear message rather than shipping untranslated. + const health = await checkOllama(settings.translateOllamaUrl, settings.translateModel, signal); + if (!health.ok) { + throw new Error(`Subtitle translation is enabled but ${health.detail}`); + } + + // All productions share one source track (planTargetLanguages guarantees it). + const sourceIndex = plan.productions[0]!.sourceIndex; + const sourceStream = subtitleStreams.find((s) => s.index === sourceIndex); + if (!sourceStream || !isTextSubtitleCodec(sourceStream.codec)) { + Logger.warn("[translate] Source track is missing or not text-based; skipping translation"); + return []; + } + + const extracted = await extractSource(inputPath, sourceStream, tempDir, signal); + if (!extracted) return []; + + const sourceTrackType = detectSubtitleTrackType(sourceStream); + const { content: styledSource, format } = await styleSource( + extracted.path, + extracted.codec, + sourceTrackType, + settings, + subtitleStyle, + tempDir, + sourceStream.index, + signal, + ); + + const dialogueStyles = format === "ass" ? dialogueStyleNames(styledSource) : new Set(); + + const out: TranslatedTrack[] = []; + const langCount = plan.productions.length; + + for (let li = 0; li < plan.productions.length; li++) { + const prod = plan.productions[li]!; + const ollama: OllamaOptions = { + url: settings.translateOllamaUrl, + model: settings.translateModel, + source: prod.source, + target: prod.target, + numCtx: settings.translateNumCtx, + timeoutMs: settings.translateTimeoutMs, + signal, + }; + + const translated = await translateSubtitleContent(styledSource, { + format, + batchSize: settings.translateBatchSize, + translateSignsSongs: settings.translateSignsSongs, + isDialogueStyle: (style) => dialogueStyles.has(style), + ollama, + onProgress: (done, total) => onProgress?.({ lang: prod.target.name, langIndex: li, langCount, done, total }), + }); + + const ext = format === "ass" ? "ass" : "srt"; + const file = join(tempDir, `translated_${prod.targetTag}_${sourceStream.index}.${ext}`); + writeFileSync(file, translated, "utf-8"); + + out.push({ + file, + language: prod.targetTag, + trackName: buildSubtitleTrackName(prod.trackType, undefined, organization), + trackType: prod.trackType, + flagArgs: computeTranslatedFlagArgs(prod.trackType), + format, + sourceIndex: sourceStream.index, + }); + + Logger.info(`[translate] Produced ${prod.target.name} (${prod.trackType}) from track ${sourceStream.index}`); + } + + return out; +} + +// Output ordering + +/** One subtitle track's mkvmerge parameters, ready to append. */ +export interface SubtitleEmit { + language: string; + trackName: string; + flagArgs: string[]; + file: string; +} + +export interface NativeEmitItem { + stream: SubtitleStreamInfo; + emit: SubtitleEmit; +} +export interface TranslatedEmitItem { + sourceIndex: number; + emit: SubtitleEmit; +} + +const SYNTH_INDEX_BASE = 1_000_000; + +/** + * Order native + translated subtitle tracks identically to how native tracks + * alone would be ordered. Each translated track is represented as a synthetic + * stream cloned from its source (so every field the comparator reads is + * populated), with language/title swapped to the target + organization group. + * The real `sortSubtitleStreams` comparator is injected and run over the + * combined set, then results map back to their mkvmerge args by index. + * + * Result: translated languages slot into the normal priority order - the only + * visible difference is the organization release-group tag in their name. + */ +export function orderOutputSubtitles( + natives: NativeEmitItem[], + translated: TranslatedEmitItem[], + sourceStreams: SubtitleStreamInfo[], + sort: (streams: SubtitleStreamInfo[]) => SubtitleStreamInfo[], +): SubtitleEmit[] { + const byIndex = new Map(); + const streams: SubtitleStreamInfo[] = []; + + for (const n of natives) { + streams.push(n.stream); + byIndex.set(n.stream.index, n.emit); + } + + translated.forEach((t, i) => { + const base = sourceStreams.find((s) => s.index === t.sourceIndex) ?? natives[0]?.stream; + const synthIndex = SYNTH_INDEX_BASE + i; + const codec = t.emit.file.toLowerCase().endsWith(".ass") ? "ass" : "subrip"; + const synth: SubtitleStreamInfo = { + ...(base as SubtitleStreamInfo), + index: synthIndex, + language: t.emit.language, + title: t.emit.trackName, + codec, + }; + streams.push(synth); + byIndex.set(synthIndex, t.emit); + }); + + const ordered = sort(streams); + const out: SubtitleEmit[] = []; + for (const s of ordered) { + const emit = byIndex.get(s.index); + if (emit) out.push(emit); + } + return out; +} diff --git a/src/types.ts b/src/types.ts index 62cfe17..95d6f94 100644 --- a/src/types.ts +++ b/src/types.ts @@ -221,6 +221,23 @@ export interface JobSettings { removeUnusedFonts: boolean; /** Selected font group (folder label under the user fonts dir). */ fontGroup: string; + // Subtitle translation (Ollama / TranslateGemma) + /** Master switch: translate missing target languages via Ollama. */ + translateSubtitles: boolean; + /** Ollama base URL, e.g. "http://localhost:11434". */ + translateOllamaUrl: string; + /** Model tag, e.g. "translategemma:12b". */ + translateModel: string; + /** Languages to ensure exist (ISO-639-2 or -1, e.g. ["eng","deu","fra","slv"]). */ + translateTargetLanguages: string[]; + /** Dialogs sent to the model per request. Lower = better context, slower. */ + translateBatchSize: number; + /** Also translate sign/song lines (keeps signs consistent in the target track). */ + translateSignsSongs: boolean; + /** Ollama context window (num_ctx). TranslateGemma supports up to 131072. */ + translateNumCtx: number; + /** Per-request timeout, ms. */ + translateTimeoutMs: number; /** * Ordered list of VapourSynth filter passes to apply during the prepare * stage, before the FFmpeg -vf chain. Each entry references a preset by diff --git a/tests/chunks.test.ts b/tests/chunks.test.ts new file mode 100644 index 0000000..9e0407c --- /dev/null +++ b/tests/chunks.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "bun:test"; +import { planChunks } from "../src/subtitle-translate"; + +describe("planChunks", () => { + it("returns a single chunk when count <= batchSize", () => { + const starts = [0, 1000, 2000]; + const ends = [900, 1900, 2900]; + expect(planChunks(starts, ends, 40)).toEqual([[0, 3]]); + }); + + it("returns empty for no lines", () => { + expect(planChunks([], [], 40)).toEqual([]); + }); + + it("splits at the largest pause within the ±20% window (spec example)", () => { + // 100 evenly-spaced lines, 1s each, 100ms gaps - except one big 5s gap + // placed at boundary b=44 (inside the window [32,48] for batchSize 40). + const n = 100; + const starts: number[] = []; + const ends: number[] = []; + let t = 0; + for (let i = 0; i < n; i++) { + starts.push(t); + ends.push(t + 1000); + // gap before the NEXT line + const gap = i === 43 ? 5000 : 100; // big pause opens before line 44 + t = t + 1000 + gap; + } + const chunks = planChunks(starts, ends, 40); + // First boundary should snap to 44 (the big pause), not the nominal 40. + expect(chunks[0]).toEqual([0, 44]); + }); + + it("falls back to the nominal boundary when gaps are uniform", () => { + const n = 90; + const starts: number[] = []; + const ends: number[] = []; + let t = 0; + for (let i = 0; i < n; i++) { + starts.push(t); + ends.push(t + 1000); + t += 1100; // uniform 100ms gaps + } + const chunks = planChunks(starts, ends, 40); + // All candidate gaps equal -> first max wins at lo = 40 - 8 = 32. + expect(chunks[0]).toEqual([0, 32]); + }); + + it("covers the full range contiguously with no gaps or overlaps", () => { + const n = 137; + const starts = Array.from({ length: n }, (_, i) => i * 1000); + const ends = starts.map((s) => s + 800); + const chunks = planChunks(starts, ends, 40); + expect(chunks[0]![0]).toBe(0); + expect(chunks[chunks.length - 1]![1]).toBe(n); + for (let k = 1; k < chunks.length; k++) { + expect(chunks[k]![0]).toBe(chunks[k - 1]![1]); + } + }); + + it("handles window=0 (tiny batch sizes) by cutting at the nominal boundary", () => { + const n = 10; + const starts = Array.from({ length: n }, (_, i) => i * 1000); + const ends = starts.map((s) => s + 500); + // batchSize 3 -> window = round(0.6) = 1, so this still searches; use 2 -> round(0.4)=0 + const chunks = planChunks(starts, ends, 2); + expect(chunks[0]).toEqual([0, 2]); + expect(chunks).toEqual([ + [0, 2], + [2, 4], + [4, 6], + [6, 8], + [8, 10], + ]); + }); +}); diff --git a/tests/ollama.test.ts b/tests/ollama.test.ts new file mode 100644 index 0000000..3516bb1 --- /dev/null +++ b/tests/ollama.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, afterEach } from "bun:test"; +import { translateBatch, translateOne, buildTranslatePrompt } from "../src/ollama"; +import { planTargetLanguages, type KeptSubDescriptor } from "../src/subtitle-translate"; +import type { OllamaOptions } from "../src/ollama"; + +const EN = { name: "English", code: "en" }; +const SL = { name: "Slovenian", code: "sl" }; + +function opts(overrides: Partial = {}): OllamaOptions { + return { url: "http://localhost:11434", model: "translategemma:12b", source: EN, target: SL, ...overrides }; +} + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +/** Install a fake fetch that returns `content` as the chat message. */ +function mockChat(handler: (body: any) => string) { + globalThis.fetch = (async (_url: string, init: any) => { + const body = JSON.parse(init.body); + const content = handler(body); + return new Response(JSON.stringify({ message: { content } }), { status: 200 }); + }) as unknown as typeof fetch; +} + +describe("ollama.buildTranslatePrompt", () => { + it("uses the TranslateGemma structure with names and codes", () => { + const p = buildTranslatePrompt(EN, SL, "Hello"); + expect(p).toContain("professional English (en) to Slovenian (sl) translator"); + expect(p).toContain("Please translate the following English text into Slovenian:\n\n\nHello"); + }); +}); + +describe("ollama.translateBatch", () => { + it("maps N lines to N returned lines in order", async () => { + mockChat((body) => { + // The block is the last line group of the prompt. + const prompt: string = body.messages[0].content; + const block = prompt.split("Slovenian:\n\n\n")[1]!; + return block + .split("\n") + .map((l) => `sl:${l}`) + .join("\n"); + }); + const out = await translateBatch(["one", "two", "three"], opts()); + expect(out).toEqual(["sl:one", "sl:two", "sl:three"]); + }); + + it("passes empty lines through without translating them", async () => { + mockChat((body) => { + const block = body.messages[0].content.split("Slovenian:\n\n\n")[1]!; + return block + .split("\n") + .map((l: string) => l.toUpperCase()) + .join("\n"); + }); + const out = await translateBatch(["a", "", "b"], opts()); + expect(out).toEqual(["A", "", "B"]); + }); + + it("falls back to per-line when the batch count is misaligned", async () => { + let calls = 0; + globalThis.fetch = (async (_url: string, init: any) => { + calls++; + const prompt = JSON.parse(init.body).messages[0].content; + const block = prompt.split("Slovenian:\n\n\n")[1]!; + const lineCount = block.split("\n").length; + // First (batch) call: return the WRONG number of lines to force fallback. + // Subsequent (per-line) calls each have a single-line block. + const content = lineCount > 1 ? "merged everything into one line" : `X:${block}`; + return new Response(JSON.stringify({ message: { content } }), { status: 200 }); + }) as unknown as typeof fetch; + + const out = await translateBatch(["one", "two"], opts()); + expect(out).toEqual(["X:one", "X:two"]); + expect(calls).toBe(3); // 1 failed batch + 2 per-line + }); + + it("throws on HTTP errors", async () => { + globalThis.fetch = (async () => new Response("boom", { status: 500 })) as unknown as typeof fetch; + await expect(translateOne("hi", opts())).rejects.toThrow(/Ollama HTTP 500/); + }); +}); + +describe("planTargetLanguages", () => { + const track = (index: number, language: string, trackType: string, codec = "ass"): KeptSubDescriptor => ({ + index, + language, + trackType, + codec, + }); + + it("skips languages that already have a full or honorifics track", () => { + const tracks = [track(0, "eng", "full"), track(1, "ger", "full"), track(2, "fre", "full")]; + const plan = planTargetLanguages(tracks, ["eng", "deu", "fra", "slv"]); + expect(plan.productions.map((p) => p.target.code)).toEqual(["sl"]); + expect(plan.productions[0]!.sourceIndex).toBe(0); // English source (top) + }); + + it("counts an existing Slovenian honorifics track as covering Slovenian", () => { + const tracks = [track(0, "eng", "full"), track(1, "slv", "honorifics")]; + const plan = planTargetLanguages(tracks, ["slv"]); + expect(plan.productions.length).toBe(0); + }); + + it("skips unsupported target languages with a note", () => { + const tracks = [track(0, "eng", "full")]; + const plan = planTargetLanguages(tracks, ["tlh", "slv"]); + expect(plan.productions.map((p) => p.target.code)).toEqual(["sl"]); + expect(plan.skipped.some((s) => s.startsWith("tlh"))).toBe(true); + }); + + it("does not translate the source language into itself", () => { + const tracks = [track(0, "eng", "full")]; + const plan = planTargetLanguages(tracks, ["eng", "slv"]); + expect(plan.productions.map((p) => p.target.code)).toEqual(["sl"]); + }); +}); diff --git a/tests/ordering.test.ts b/tests/ordering.test.ts new file mode 100644 index 0000000..25324c8 --- /dev/null +++ b/tests/ordering.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test"; +import { orderOutputSubtitles, type NativeEmitItem, type TranslatedEmitItem } from "../src/translate-step"; +import { buildSubtitleTrackName, sortSubtitleStreams } from "../src/tracks"; +import type { SubtitleStreamInfo } from "../src/types"; + +const emit = (language: string, trackName: string, file: string) => ({ + language, + trackName, + flagArgs: ["--default-track-flag", "0:1"], + file, +}); + +describe("organization attribution", () => { + it("stamps the organization as the release group, not the source group", () => { + expect(buildSubtitleTrackName("full", "English (SubsPlease)", "RabbitCompany")).toBe("Full Subtitles [RabbitCompany]"); + expect(buildSubtitleTrackName("honorifics", undefined, "RabbitCompany")).toBe("Full Subtitles (Honorifics) [RabbitCompany]"); + }); +}); + +describe("orderOutputSubtitles", () => { + const stream = (index: number, language: string, title = ""): SubtitleStreamInfo => ({ index, codec: "ass", language, title }); + + const sorter = (langPriority: string[]) => (streams: SubtitleStreamInfo[]) => sortSubtitleStreams(streams, { languagePriority: langPriority }); + + it("interleaves translated tracks into the normal language-priority order", () => { + // Priority: eng, slv, deu, then wildcard. Native has only English. + const natives: NativeEmitItem[] = [{ stream: stream(0, "eng", "English [SubsPlease]"), emit: emit("eng", "Full Subtitles [SubsPlease]", "eng.ass") }]; + const translated: TranslatedEmitItem[] = [ + { sourceIndex: 0, emit: emit("deu", "Full Subtitles [RabbitCompany]", "deu.ass") }, + { sourceIndex: 0, emit: emit("slv", "Full Subtitles [RabbitCompany]", "slv.ass") }, + ]; + const ordered = orderOutputSubtitles(natives, translated, [stream(0, "eng")], sorter(["eng", "slv", "deu", "*"])); + expect(ordered.map((e) => e.language)).toEqual(["eng", "slv", "deu"]); + // The translated tracks carry the org tag; the native keeps its own group. + expect(ordered[0]!.trackName).toBe("Full Subtitles [SubsPlease]"); + expect(ordered[1]!.trackName).toBe("Full Subtitles [RabbitCompany]"); + }); + + it("keeps native relative order and appends unmatched-language translations under the wildcard", () => { + const natives: NativeEmitItem[] = [ + { stream: stream(0, "eng"), emit: emit("eng", "Full Subtitles [A]", "eng.ass") }, + { stream: stream(1, "jpn"), emit: emit("jpn", "Full Subtitles [B]", "jpn.ass") }, + ]; + const translated: TranslatedEmitItem[] = [{ sourceIndex: 0, emit: emit("slv", "Full Subtitles [Rabbit]", "slv.ass") }]; + const ordered = orderOutputSubtitles(natives, translated, [stream(0, "eng"), stream(1, "jpn")], sorter(["eng", "jpn", "*"])); + expect(ordered.map((e) => e.language)).toEqual(["eng", "jpn", "slv"]); + }); + + it("returns only native tracks when there are no translations", () => { + const natives: NativeEmitItem[] = [{ stream: stream(0, "eng"), emit: emit("eng", "Full Subtitles", "eng.ass") }]; + const ordered = orderOutputSubtitles(natives, [], [stream(0, "eng")], sorter(["eng", "*"])); + expect(ordered.length).toBe(1); + expect(ordered[0]!.file).toBe("eng.ass"); + }); +}); diff --git a/tests/subtitle-edit.test.ts b/tests/subtitle-edit.test.ts new file mode 100644 index 0000000..fc625ce --- /dev/null +++ b/tests/subtitle-edit.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "bun:test"; +import { parseSrt, buildSrt } from "../src/srt-edit"; +import { parseAssEvents, splitAssText, joinAssText, buildTranslatedAss, assTimeToMs } from "../src/ass-edit"; +import { resolveTranslateLang, isTranslatable } from "../src/translate-languages"; + +describe("srt-edit", () => { + const SRT = ["1", "00:00:01,000 --> 00:00:03,500", "Hello world", "", "2", "00:00:04,000 --> 00:00:06,000", "Two", "lines", ""].join("\n"); + + it("parses cues with timing in ms and multi-line text", () => { + const cues = parseSrt(SRT); + expect(cues.length).toBe(2); + expect(cues[0]!.startMs).toBe(1000); + expect(cues[0]!.endMs).toBe(3500); + expect(cues[1]!.text).toBe("Two\nlines"); + }); + + it("round-trips through build with renumbering", () => { + const cues = parseSrt(SRT); + const rebuilt = buildSrt(cues); + const reparsed = parseSrt(rebuilt); + expect(reparsed.map((c) => c.text)).toEqual(["Hello world", "Two\nlines"]); + expect(reparsed[0]!.timingLine).toBe("00:00:01,000 --> 00:00:03,500"); + }); + + it("tolerates a missing index line", () => { + const noIdx = "00:00:01,000 --> 00:00:02,000\nHi\n"; + const cues = parseSrt(noIdx); + expect(cues.length).toBe(1); + expect(cues[0]!.text).toBe("Hi"); + }); +}); + +describe("ass-edit timing", () => { + it("parses centisecond ASS timecodes", () => { + expect(assTimeToMs("0:00:01.50")).toBe(1500); + expect(assTimeToMs("1:02:03.04")).toBe(3_723_040); + }); +}); + +const ASS = [ + "[Script Info]", + "ScriptType: v4.00+", + "PlayResX: 1920", + "PlayResY: 1080", + "", + "[V4+ Styles]", + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding", + "Style: Default,Arial,60,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,0,2,60,60,60,1", + "Style: Sign,Arial,60,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,0,7,10,10,10,1", + "", + "[Events]", + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text", + "Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello, world", + "Comment: 0,0:00:03.00,0:00:04.00,Default,,0,0,0,,ignore me", + "Dialogue: 0,0:00:05.00,0:00:07.00,Default,,0,0,0,,{\\i1}A quote{\\i0} said {\\b1}Bob{\\b0}", + "Dialogue: 0,0:00:08.00,0:00:10.00,Sign,,0,0,0,,{\\pos(500,300)}SHOP", + "Dialogue: 0,0:00:11.00,0:00:12.00,Sign,,0,0,0,,{\\p1}m 0 0 l 10 0 10 10 0 10{\\p0}", + "", +].join("\n"); + +describe("ass-edit", () => { + it("parses only Dialogue events (skips Comment) with commas preserved in Text", () => { + const { events } = parseAssEvents(ASS); + expect(events.length).toBe(4); + expect(events[0]!.rawText).toBe("Hello, world"); + expect(events[0]!.startMs).toBe(1000); + expect(events[0]!.style).toBe("Default"); + }); + + it("splits leading tags, strips mid-text tags, keeps drawings out", () => { + const quote = splitAssText("{\\i1}A quote{\\i0} said {\\b1}Bob{\\b0}"); + expect(quote.lead).toBe("{\\i1}"); + expect(quote.visible).toBe("A quote said Bob"); + expect(quote.translatable).toBe(true); + + const sign = splitAssText("{\\pos(500,300)}SHOP"); + expect(sign.lead).toBe("{\\pos(500,300)}"); + expect(sign.visible).toBe("SHOP"); + + const drawing = splitAssText("{\\p1}m 0 0 l 10 0{\\p0}"); + expect(drawing.translatable).toBe(false); + + const empty = splitAssText("{\\pos(1,1)}\\N"); + expect(empty.translatable).toBe(false); + }); + + it("rebuilds swapping only translated Text, leaving all else byte-identical", () => { + const { events } = parseAssEvents(ASS); + const edits = new Map(); + for (const ev of events) { + const parts = splitAssText(ev.rawText); + if (!parts.translatable) continue; + // Fake "translation": uppercase, reattach lead, drop mid tags. + edits.set(ev.lineNo, joinAssText(parts.lead, parts.visible.toUpperCase())); + } + const out = buildTranslatedAss(ASS, edits, events); + + expect(out).toContain("Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,HELLO, WORLD"); + expect(out).toContain("Dialogue: 0,0:00:05.00,0:00:07.00,Default,,0,0,0,,{\\i1}A QUOTE SAID BOB"); + expect(out).toContain("Dialogue: 0,0:00:08.00,0:00:10.00,Sign,,0,0,0,,{\\pos(500,300)}SHOP"); + // Untouched lines survive verbatim: + expect(out).toContain("Comment: 0,0:00:03.00,0:00:04.00,Default,,0,0,0,,ignore me"); + expect(out).toContain("Style: Default,Arial,60"); + expect(out).toContain("PlayResX: 1920"); + // Drawing line untouched: + expect(out).toContain("{\\p1}m 0 0 l 10 0 10 10 0 10{\\p0}"); + }); +}); + +describe("translate-languages", () => { + it("resolves ISO-639-2 tags the MKV side uses", () => { + expect(resolveTranslateLang("eng")).toEqual({ name: "English", code: "en" }); + expect(resolveTranslateLang("deu")!.code).toBe("de"); + expect(resolveTranslateLang("ger")!.code).toBe("de"); // bibliographic + expect(resolveTranslateLang("fre")!.code).toBe("fr"); + expect(resolveTranslateLang("slv")).toEqual({ name: "Slovenian", code: "sl" }); + }); + + it("treats the honorifics pseudo-tag en-JP as English", () => { + expect(resolveTranslateLang("en-JP")!.code).toBe("en"); + }); + + it("disambiguates Chinese script", () => { + expect(resolveTranslateLang("zh-Hant")!.code).toBe("zh-Hant"); + expect(resolveTranslateLang("zh-TW")!.code).toBe("zh-TW"); + expect(resolveTranslateLang("zho")!.code).toBe("zh-Hans"); + }); + + it("returns null for unsupported languages", () => { + expect(resolveTranslateLang("tlh")).toBeNull(); // Klingon + expect(isTranslatable("xyz")).toBe(false); + expect(isTranslatable("slv")).toBe(true); + }); +}); diff --git a/tests/translate-step.test.ts b/tests/translate-step.test.ts new file mode 100644 index 0000000..ae00fb6 --- /dev/null +++ b/tests/translate-step.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { computeTranslatedFlagArgs, resolveOutputFormat, buildKeptDescriptors } from "../src/translate-step"; +import type { SubtitleStreamInfo } from "../src/types"; + +describe("translate-step helpers", () => { + it("marks a translated track default for its new language", () => { + const full = computeTranslatedFlagArgs("full"); + expect(full).toContain("--default-track-flag"); + expect(full[full.indexOf("--default-track-flag") + 1]).toBe("0:1"); + expect(full[full.indexOf("--forced-display-flag") + 1]).toBe("0:0"); + + const hon = computeTranslatedFlagArgs("honorifics"); + expect(hon[hon.indexOf("--default-track-flag") + 1]).toBe("0:1"); + }); + + it("keeps ASS as ASS and honors convertSrtToAss for SRT", () => { + expect(resolveOutputFormat("ass", false)).toBe("ass"); + expect(resolveOutputFormat("ssa", false)).toBe("ass"); + expect(resolveOutputFormat("subrip", false)).toBe("srt"); + expect(resolveOutputFormat("subrip", true)).toBe("ass"); + }); + + it("applies the honorifics language convention when building descriptors", () => { + const streams: SubtitleStreamInfo[] = [ + { index: 0, codec: "ass", language: "eng" }, + { index: 1, codec: "ass", language: "eng" }, + ]; + const desc = buildKeptDescriptors(streams, (s) => (s.index === 1 ? "honorifics" : "full")); + expect(desc[0]).toEqual({ index: 0, codec: "ass", language: "eng", trackType: "full" }); + expect(desc[1]!.language).toBe("en-JP"); + expect(desc[1]!.trackType).toBe("honorifics"); + }); +}); From 39aac2c2563a7966d451bec6aa10a88a2c8de074 Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Thu, 2 Jul 2026 17:33:09 +0200 Subject: [PATCH 02/13] Implement parallel processing --- public/types.ts | 4 + src/concurrency.ts | 42 ++ src/encoder.ts | 1191 +++++++++++++++++++------------------ src/subtitle-translate.ts | 29 +- src/translate-step.ts | 102 ++-- src/types.ts | 4 + 6 files changed, 745 insertions(+), 627 deletions(-) create mode 100644 src/concurrency.ts diff --git a/public/types.ts b/public/types.ts index 16e9271..a4d1010 100644 --- a/public/types.ts +++ b/public/types.ts @@ -213,6 +213,10 @@ export interface JobSettings { translateNumCtx: number; /** Per-request timeout, ms. */ translateTimeoutMs: number; + /** Max concurrent in-flight Ollama requests (all languages + chunks). Keep <= server OLLAMA_NUM_PARALLEL. Default 1. */ + translateConcurrency?: number; + /** May translation overlap the video encode? "auto" overlaps only when Ollama is NOT on a loopback address. Default "auto". */ + translateDuringEncode?: "auto" | "always" | "never"; /** * Ordered list of VapourSynth filter passes to apply during the prepare * stage, before the FFmpeg -vf chain. Each entry references a preset by diff --git a/src/concurrency.ts b/src/concurrency.ts new file mode 100644 index 0000000..ee83e6f --- /dev/null +++ b/src/concurrency.ts @@ -0,0 +1,42 @@ +export interface Semaphore { + /** Run `task` once a slot is free; never more than `limit` run at once. */ + run(task: () => Promise): Promise; +} + +/** A minimal counting semaphore (single-process, single-threaded). */ +export function createSemaphore(limit: number): Semaphore { + const max = Math.max(1, Math.floor(limit)); + let active = 0; + const waiters: Array<() => void> = []; + + const pump = () => { + if (active >= max) return; + const wake = waiters.shift(); + if (wake) { + active++; + wake(); + } + }; + + return { + run(task: () => Promise): Promise { + return new Promise((resolve, reject) => { + waiters.push(() => { + task() + .then(resolve, reject) + .finally(() => { + active--; + pump(); + }); + }); + pump(); + }); + }, + }; +} + +/** map with bounded concurrency; preserves input order in the result. */ +export async function mapPool(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise): Promise { + const sem = createSemaphore(limit); + return Promise.all(items.map((item, i) => sem.run(() => fn(item, i)))); +} diff --git a/src/encoder.ts b/src/encoder.ts index edc1511..6d44110 100644 --- a/src/encoder.ts +++ b/src/encoder.ts @@ -612,714 +612,749 @@ export async function encodeJob( const skipVideoEncode = job.settings.videoEncode === "off"; const skipAudioEncode = job.settings.audioEncode === "copy"; const skipSubtitleProcessing = job.settings.subtitleProcessing === "copy"; + const audioDetect = { + commentary: job.settings.detectCommentaryAudio, + descriptive: job.settings.detectDescriptiveAudio, + karaoke: job.settings.detectKaraokeAudio, + }; - if (skipVideoEncode) { - for (const si of [S_FAST, S_METRICS, S_SCENES, S_ZONES, S_FINAL]) { - setStep(si, { status: "done", progress: 100, detail: "Skipped — video encoding off" }); - } + const ollamaIsLoopback = /(?:\/\/)(?:localhost|127\.0\.0\.1|\[?::1\]?|0\.0\.0\.0)(?::|\/|$)/i.test(job.settings.translateOllamaUrl); + const overlapPolicy = job.settings.translateDuringEncode ?? "auto"; + const overlapTranslate = overlapPolicy === "always" || (overlapPolicy === "auto" && !ollamaIsLoopback); + + const stageAbort = new AbortController(); + const stageSignal = stageAbort.signal; + const onOuterAbort = () => stageAbort.abort((signal as any)?.reason ?? new CancelledError()); + if (signal) { + if (signal.aborted) stageAbort.abort((signal as any).reason); + else signal.addEventListener("abort", onOuterAbort, { once: true }); } let videoMkv: string; + let audioStreams: AudioStreamInfo[] = []; + const encodedAudioFiles: string[] = []; + let subtitleStreams: SubtitleStreamInfo[] = []; + let translatedTracks: TranslatedTrack[] = []; - if (!skipVideoEncode) { - // ABE or direct encode - checkCancelled(); - updateJob({ status: "encoding_video" }); - - const ivfFile = join(tempDir, "source_video.ivf"); - const inProgressIvf = join(tempDir, "abe_temp", "source_video.ivf"); - const enc = getEncoder(job.settings.encoder); - - const estimatedAudioStreams = (probe.audioStreams || []).filter((s) => !s.title || !/compatibility/i.test(s.title)); - const estimatedAudioBytes = Math.round( - ((estimatedAudioStreams.reduce((sum, s) => { - const layout = normalizeLayout(s.channelLayout); - return sum + getOpusBitrateForLayout(layout, job.settings.audioBitrates); - }, 0) * - 1000) / - 8) * - probe.duration, - ); - - if (enc.usesAutoBoost) { - const colorParams = svtColorParamsFromProbe(probe); - const custom = job.settings.customEncoderParams?.trim() ?? ""; - const finalParams = custom ? `${colorParams} ${custom}` : colorParams; - - const abeArgs = [ - "python3", - "-u", - "/opt/Auto-Boost-Essential/Auto-Boost-Essential.py", - "-i", - preparedVideo, - "-t", - join(tempDir, "abe_temp"), - "--quality", - job.settings.quality, - "--final-speed", - job.settings.finalSpeed, - "--fast-params", - colorParams, - "--final-params", - finalParams, - "--json-stream", - ]; - - if (job.settings.skipBoosting) { - abeArgs.push("-nb"); + const encodeVideo = async (): Promise => { + if (skipVideoEncode) { + for (const si of [S_FAST, S_METRICS, S_SCENES, S_ZONES, S_FINAL]) { + setStep(si, { status: "done", progress: 100, detail: "Skipped — video encoding off" }); } + } - const abeProc = Bun.spawn(abeArgs, { - stdout: "pipe", - stderr: "pipe", - cwd: tempDir, - }); - - const onAbortAbe = () => { - try { - abeProc.kill("SIGTERM"); - } catch {} - setTimeout(() => { - try { - abeProc.kill("SIGKILL"); - } catch {} - }, 3000); - }; - signal?.addEventListener("abort", onAbortAbe, { once: true }); - - const abeStageToStep: Record = { - 0: S_FAST, - 1: S_METRICS, - 2: S_SCENES, - 3: S_ZONES, - 4: S_FINAL, - }; + if (!skipVideoEncode) { + // ABE or direct encode + checkCancelled(); + updateJob({ status: "encoding_video" }); + + const ivfFile = join(tempDir, "source_video.ivf"); + const inProgressIvf = join(tempDir, "abe_temp", "source_video.ivf"); + const enc = getEncoder(job.settings.encoder); + + const estimatedAudioStreams = (probe.audioStreams || []).filter((s) => !s.title || !/compatibility/i.test(s.title)); + const estimatedAudioBytes = Math.round( + ((estimatedAudioStreams.reduce((sum, s) => { + const layout = normalizeLayout(s.channelLayout); + return sum + getOpusBitrateForLayout(layout, job.settings.audioBitrates); + }, 0) * + 1000) / + 8) * + probe.duration, + ); - let abeStderr = ""; - let abeLastError = ""; + if (enc.usesAutoBoost) { + const colorParams = svtColorParamsFromProbe(probe); + const custom = job.settings.customEncoderParams?.trim() ?? ""; + const finalParams = custom ? `${colorParams} ${custom}` : colorParams; - const handleAbeEvent = (evt: any) => { - const si = abeStageToStep[evt.stage]; + const abeArgs = [ + "python3", + "-u", + "/opt/Auto-Boost-Essential/Auto-Boost-Essential.py", + "-i", + preparedVideo, + "-t", + join(tempDir, "abe_temp"), + "--quality", + job.settings.quality, + "--final-speed", + job.settings.finalSpeed, + "--fast-params", + colorParams, + "--final-params", + finalParams, + "--json-stream", + ]; - if (evt.event === "stage" && si !== undefined) { - setStep(si, { - status: "active", - progress: 0, - detail: evt.total_frames ? fmtFrames(0, evt.total_frames) : undefined, - }); - return; + if (job.settings.skipBoosting) { + abeArgs.push("-nb"); } - if (evt.event === "progress" && si !== undefined) { - let estVideo: string | undefined; - let estTotal: string | undefined; + const abeProc = Bun.spawn(abeArgs, { + stdout: "pipe", + stderr: "pipe", + cwd: tempDir, + }); - if (evt.stage === 4 && evt.current > 0 && evt.total > 0) { - const frac = evt.current / evt.total; - if (frac >= 0.02) { - try { - const curBytes = statSync(inProgressIvf).size; - const estVideoBytes = curBytes / frac; - const estTotalBytes = estVideoBytes + estimatedAudioBytes + 2 * 1024 * 1024; - estVideo = humanSize(estVideoBytes); - estTotal = humanSize(estTotalBytes); - updateJob({ - estimatedVideoSize: estVideo, - estimatedFinalSize: estTotal, - }); - } catch {} - } - } + const onAbortAbe = () => { + try { + abeProc.kill("SIGTERM"); + } catch {} + setTimeout(() => { + try { + abeProc.kill("SIGKILL"); + } catch {} + }, 3000); + }; + stageSignal?.addEventListener("abort", onAbortAbe, { once: true }); + + const abeStageToStep: Record = { + 0: S_FAST, + 1: S_METRICS, + 2: S_SCENES, + 3: S_ZONES, + 4: S_FINAL, + }; - setStep(si, { - progress: pct2(evt.current, evt.total), - detail: evt.total ? fmtFramesWithFps(evt.current, evt.total, steps[si]!.startedAt, estVideo, estTotal) : undefined, - }); - return; - } + let abeStderr = ""; + let abeLastError = ""; - if (evt.event === "stage_complete" && si !== undefined) { - setStep(si, { - status: "done", - progress: 100, - detail: evt.total_frames ? fmtFramesWithFps(evt.total_frames, evt.total_frames, steps[si]!.startedAt) : steps[si]!.detail, - }); - return; - } + const handleAbeEvent = (evt: any) => { + const si = abeStageToStep[evt.stage]; - if (evt.event === "error") { - abeLastError = evt.message || "Unknown error"; - Logger.error("[ABE error]", { message: evt.message }); - } - }; + if (evt.event === "stage" && si !== undefined) { + setStep(si, { + status: "active", + progress: 0, + detail: evt.total_frames ? fmtFrames(0, evt.total_frames) : undefined, + }); + return; + } - const abeStdoutTask = (async () => { - if (!abeProc.stdout) return; + if (evt.event === "progress" && si !== undefined) { + let estVideo: string | undefined; + let estTotal: string | undefined; - try { - const reader = abeProc.stdout.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; + if (evt.stage === 4 && evt.current > 0 && evt.total > 0) { + const frac = evt.current / evt.total; + if (frac >= 0.02) { + try { + const curBytes = statSync(inProgressIvf).size; + const estVideoBytes = curBytes / frac; + const estTotalBytes = estVideoBytes + estimatedAudioBytes + 2 * 1024 * 1024; + estVideo = humanSize(estVideoBytes); + estTotal = humanSize(estTotalBytes); + updateJob({ + estimatedVideoSize: estVideo, + estimatedFinalSize: estTotal, + }); + } catch {} + } + } - while (true) { - try { - const { done, value } = await reader.read(); - if (done) break; + setStep(si, { + progress: pct2(evt.current, evt.total), + detail: evt.total ? fmtFramesWithFps(evt.current, evt.total, steps[si]!.startedAt, estVideo, estTotal) : undefined, + }); + return; + } - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; + if (evt.event === "stage_complete" && si !== undefined) { + setStep(si, { + status: "done", + progress: 100, + detail: evt.total_frames ? fmtFramesWithFps(evt.total_frames, evt.total_frames, steps[si]!.startedAt) : steps[si]!.detail, + }); + return; + } - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line) continue; + if (evt.event === "error") { + abeLastError = evt.message || "Unknown error"; + Logger.error("[ABE error]", { message: evt.message }); + } + }; - try { - const evt = JSON.parse(line); - handleAbeEvent(evt); - } catch { - Logger.warn(`[ABE stdout non-json]`, { output: rawLine }); + const abeStdoutTask = (async () => { + if (!abeProc.stdout) return; + + try { + const reader = abeProc.stdout.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + try { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + + try { + const evt = JSON.parse(line); + handleAbeEvent(evt); + } catch { + Logger.warn(`[ABE stdout non-json]`, { output: rawLine }); + } } + } catch { + break; } - } catch { - break; } - } - buffer += decoder.decode(); + buffer += decoder.decode(); - const trailing = buffer.trim(); - if (trailing) { - try { - const evt = JSON.parse(trailing); - handleAbeEvent(evt); - } catch { - Logger.warn(`[ABE stdout trailing non-json]`, { output: trailing }); + const trailing = buffer.trim(); + if (trailing) { + try { + const evt = JSON.parse(trailing); + handleAbeEvent(evt); + } catch { + Logger.warn(`[ABE stdout trailing non-json]`, { output: trailing }); + } } - } - } catch {} - })(); + } catch {} + })(); - const abeStderrTask = (async () => { - if (!abeProc.stderr) return; + const abeStderrTask = (async () => { + if (!abeProc.stderr) return; - try { - const reader = abeProc.stderr.getReader(); - const decoder = new TextDecoder(); + try { + const reader = abeProc.stderr.getReader(); + const decoder = new TextDecoder(); - while (true) { - try { - const { done, value } = await reader.read(); - if (done) break; + while (true) { + try { + const { done, value } = await reader.read(); + if (done) break; - const chunk = decoder.decode(value, { stream: true }); - abeStderr += chunk; + const chunk = decoder.decode(value, { stream: true }); + abeStderr += chunk; - if (chunk.trim()) { - Logger.error("[ABE stderr]", { error: chunk.trimEnd() }); + if (chunk.trim()) { + Logger.error("[ABE stderr]", { error: chunk.trimEnd() }); + } + } catch { + break; } - } catch { - break; } - } - abeStderr += decoder.decode(); - } catch {} - })(); + abeStderr += decoder.decode(); + } catch {} + })(); - const [abeCode] = await Promise.all([abeProc.exited, abeStdoutTask, abeStderrTask]); - signal?.removeEventListener("abort", onAbortAbe); - checkCancelled(); + const [abeCode] = await Promise.all([abeProc.exited, abeStdoutTask, abeStderrTask]); + stageSignal?.removeEventListener("abort", onAbortAbe); + checkCancelled(); - if (abeCode !== 0) { - const exitSignal = abeCode > 128 ? describeExitCode(abeCode) : null; - const detail = abeLastError || abeStderr.trim().slice(-500) || exitSignal || "No error details available"; - throw new Error(`Auto-Boost-Essential failed (exit ${abeCode}): ${detail}`); - } + if (abeCode !== 0) { + const exitSignal = abeCode > 128 ? describeExitCode(abeCode) : null; + const detail = abeLastError || abeStderr.trim().slice(-500) || exitSignal || "No error details available"; + throw new Error(`Auto-Boost-Essential failed (exit ${abeCode}): ${detail}`); + } - if (job.settings.skipBoosting) { - // Skip boosting: mark ABE-only steps as done (skipped) + if (job.settings.skipBoosting) { + // Skip boosting: mark ABE-only steps as done (skipped) + for (const si of [S_FAST, S_METRICS, S_SCENES, S_ZONES]) { + setStep(si, { status: "done", progress: 100, detail: "Skipped" }); + } + } + } else { + // DIRECT ENCODE + // ABE-only steps don't apply (mark them skipped) for (const si of [S_FAST, S_METRICS, S_SCENES, S_ZONES]) { - setStep(si, { status: "done", progress: 100, detail: "Skipped" }); + setStep(si, { status: "done", progress: 100, detail: `Skipped — ${enc.label}` }); } - } - } else { - // DIRECT ENCODE - // ABE-only steps don't apply (mark them skipped) - for (const si of [S_FAST, S_METRICS, S_SCENES, S_ZONES]) { - setStep(si, { status: "done", progress: 100, detail: `Skipped — ${enc.label}` }); - } - setStep(S_FINAL, { status: "active", progress: 0 }); + setStep(S_FINAL, { status: "active", progress: 0 }); - const colorParams = svtColorParamsFromProbe(probe); // string of SVT --flags - const totalFrames = Math.max(1, Math.round(probe.duration * probe.videoStreamFps)); + const colorParams = svtColorParamsFromProbe(probe); // string of SVT --flags + const totalFrames = Math.max(1, Math.round(probe.duration * probe.videoStreamFps)); - const customParams = (job.settings.customEncoderParams || "").trim(); - const customList = customParams.length > 0 ? customParams.split(/\s+/) : []; + const customParams = (job.settings.customEncoderParams || "").trim(); + const customList = customParams.length > 0 ? customParams.split(/\s+/) : []; - const y4mFifo = join(tempDir, "direct_y4m.fifo"); - try { - unlinkSync(y4mFifo); - } catch {} - const mkfifoRes = await run(["mkfifo", y4mFifo], { signal }); - if (mkfifoRes.code !== 0) { - throw new Error(`Failed to create encode FIFO: ${mkfifoRes.stderr || mkfifoRes.stdout}`); - } + const y4mFifo = join(tempDir, "direct_y4m.fifo"); + try { + unlinkSync(y4mFifo); + } catch {} + const mkfifoRes = await run(["mkfifo", y4mFifo], { signal: stageSignal }); + if (mkfifoRes.code !== 0) { + throw new Error(`Failed to create encode FIFO: ${mkfifoRes.stderr || mkfifoRes.stdout}`); + } - const ffArgs = ["ffmpeg", "-nostdin", "-y", "-i", preparedVideo, "-f", "yuv4mpegpipe", "-strict", "-1", "-pix_fmt", "yuv420p10le", y4mFifo]; - const encArgs = [ - enc.binary, - "-i", - y4mFifo, - "--progress", - "2", - ...colorParams.split(/\s+/).filter(Boolean), - "--crf", - String(job.settings.manualCrf), - "--preset", - String(job.settings.manualPreset), - ...customList, - "-b", - inProgressIvf, - ]; - - mkdirSync(join(tempDir, "abe_temp"), { recursive: true }); - - const ffProc = Bun.spawn(ffArgs, { stdout: "ignore", stderr: "ignore", cwd: tempDir }); - const encProc = Bun.spawn(encArgs, { - stdout: "ignore", - stderr: "pipe", - cwd: tempDir, - }); + const ffArgs = ["ffmpeg", "-nostdin", "-y", "-i", preparedVideo, "-f", "yuv4mpegpipe", "-strict", "-1", "-pix_fmt", "yuv420p10le", y4mFifo]; + const encArgs = [ + enc.binary, + "-i", + y4mFifo, + "--progress", + "2", + ...colorParams.split(/\s+/).filter(Boolean), + "--crf", + String(job.settings.manualCrf), + "--preset", + String(job.settings.manualPreset), + ...customList, + "-b", + inProgressIvf, + ]; - const onAbort = () => { - for (const p of [ffProc, encProc]) { - try { - p.kill("SIGTERM"); - } catch {} - } - setTimeout(() => { + mkdirSync(join(tempDir, "abe_temp"), { recursive: true }); + + const ffProc = Bun.spawn(ffArgs, { stdout: "ignore", stderr: "ignore", cwd: tempDir }); + const encProc = Bun.spawn(encArgs, { + stdout: "ignore", + stderr: "pipe", + cwd: tempDir, + }); + + const onAbort = () => { for (const p of [ffProc, encProc]) { try { - p.kill("SIGKILL"); + p.kill("SIGTERM"); } catch {} } - }, 3000); - }; - signal?.addEventListener("abort", onAbort, { once: true }); + setTimeout(() => { + for (const p of [ffProc, encProc]) { + try { + p.kill("SIGKILL"); + } catch {} + } + }, 3000); + }; + stageSignal?.addEventListener("abort", onAbort, { once: true }); - // Parse SVT stderr/stdout progress for the current frame count. - let encStderr = ""; + // Parse SVT stderr/stdout progress for the current frame count. + let encStderr = ""; - const stderrTask = (async () => { - if (!encProc.stderr) return; + const stderrTask = (async () => { + if (!encProc.stderr) return; - const reader = encProc.stderr.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; + const reader = encProc.stderr.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; - const handleProgressLine = (rawLine: string) => { - const line = stripAnsiAndControls(rawLine).trim(); + const handleProgressLine = (rawLine: string) => { + const line = stripAnsiAndControls(rawLine).trim(); - // Supports: - // Encoding frame 123 - // Encoding: 123 Frames @ 8.67 fps | ... - const m = line.match(/Encoding frame\s+(\d+)/i) || line.match(/Encoding:\s*(\d+)\s+Frames?\b/i); + // Supports: + // Encoding frame 123 + // Encoding: 123 Frames @ 8.67 fps | ... + const m = line.match(/Encoding frame\s+(\d+)/i) || line.match(/Encoding:\s*(\d+)\s+Frames?\b/i); - if (!m) return; + if (!m) return; - const current = parseInt(m[1]!, 10); - if (!Number.isFinite(current) || current <= 0) return; + const current = parseInt(m[1]!, 10); + if (!Number.isFinite(current) || current <= 0) return; - let estVideo: string | undefined; - let estTotal: string | undefined; + let estVideo: string | undefined; + let estTotal: string | undefined; - const frac = current / totalFrames; + const frac = current / totalFrames; - if (frac >= 0.02) { - try { - const curBytes = statSync(inProgressIvf).size; - const estVideoBytes = curBytes / frac; - const estTotalBytes = estVideoBytes + estimatedAudioBytes + 2 * 1024 * 1024; + if (frac >= 0.02) { + try { + const curBytes = statSync(inProgressIvf).size; + const estVideoBytes = curBytes / frac; + const estTotalBytes = estVideoBytes + estimatedAudioBytes + 2 * 1024 * 1024; - estVideo = humanSize(estVideoBytes); - estTotal = humanSize(estTotalBytes); + estVideo = humanSize(estVideoBytes); + estTotal = humanSize(estTotalBytes); - updateJob({ - estimatedVideoSize: estVideo, - estimatedFinalSize: estTotal, - }); - } catch {} - } + updateJob({ + estimatedVideoSize: estVideo, + estimatedFinalSize: estTotal, + }); + } catch {} + } - setStep(S_FINAL, { - progress: pct2(current, totalFrames), - detail: fmtFramesWithFps(current, totalFrames, steps[S_FINAL]!.startedAt, estVideo, estTotal), - }); - }; + setStep(S_FINAL, { + progress: pct2(current, totalFrames), + detail: fmtFramesWithFps(current, totalFrames, steps[S_FINAL]!.startedAt, estVideo, estTotal), + }); + }; - while (true) { - let chunk; + while (true) { + let chunk; - try { - chunk = await reader.read(); - } catch { - break; - } + try { + chunk = await reader.read(); + } catch { + break; + } - if (chunk.done) break; + if (chunk.done) break; - const text = decoder.decode(chunk.value, { stream: true }); - encStderr += text; - buffer += text; + const text = decoder.decode(chunk.value, { stream: true }); + encStderr += text; + buffer += text; - const lines = buffer.split(/\r|\n/); - buffer = lines.pop() || ""; + const lines = buffer.split(/\r|\n/); + buffer = lines.pop() || ""; - for (const line of lines) { - handleProgressLine(line); + for (const line of lines) { + handleProgressLine(line); + } } - } - if (buffer.trim()) { - handleProgressLine(buffer); - } - })(); + if (buffer.trim()) { + handleProgressLine(buffer); + } + })(); - const encCode = await encProc.exited; + const encCode = await encProc.exited; - try { - ffProc.kill("SIGTERM"); - } catch {} - await ffProc.exited; - await stderrTask; + try { + ffProc.kill("SIGTERM"); + } catch {} + await ffProc.exited; + await stderrTask; - signal?.removeEventListener("abort", onAbort); + stageSignal?.removeEventListener("abort", onAbort); - try { - unlinkSync(y4mFifo); - } catch {} + try { + unlinkSync(y4mFifo); + } catch {} - checkCancelled(); + checkCancelled(); - if (encCode !== 0) { - const detail = encStderr.trim().slice(-500) || describeExitCode(encCode); - throw new Error(`${enc.label} failed (exit ${encCode}): ${detail}`); - } + if (encCode !== 0) { + const detail = encStderr.trim().slice(-500) || describeExitCode(encCode); + throw new Error(`${enc.label} failed (exit ${encCode}): ${detail}`); + } - setStep(S_FINAL, { status: "done", progress: 100 }); + setStep(S_FINAL, { status: "done", progress: 100 }); - if (existsSync(inProgressIvf)) renameSync(inProgressIvf, ivfFile); - } + if (existsSync(inProgressIvf)) renameSync(inProgressIvf, ivfFile); + } - if (!existsSync(ivfFile)) { - throw new Error("Encoder did not produce output .ivf file"); - } + if (!existsSync(ivfFile)) { + throw new Error("Encoder did not produce output .ivf file"); + } - videoMkv = join(tempDir, "video_only.mkv"); - const muxVidRes = await run(["mkvmerge", "-o", videoMkv, ivfFile], { signal }); - if (muxVidRes.code !== 0 && muxVidRes.code !== 1) { - throw new Error(`mkvmerge video: ${muxVidRes.stderr || muxVidRes.stdout}`); + videoMkv = join(tempDir, "video_only.mkv"); + const muxVidRes = await run(["mkvmerge", "-o", videoMkv, ivfFile], { signal: stageSignal }); + if (muxVidRes.code !== 0 && muxVidRes.code !== 1) { + throw new Error(`mkvmerge video: ${muxVidRes.stderr || muxVidRes.stdout}`); + } + updateJob({ encodedVideoSize: humanSize(statSync(videoMkv).size) }); + } else { + // FFV1 prepared video is the final video track. + videoMkv = preparedVideo; + setStep(S_FINAL, { status: "done", progress: 100, detail: "Skipped — video encoding off" }); } - updateJob({ encodedVideoSize: humanSize(statSync(videoMkv).size) }); - } else { - // FFV1 prepared video is the final video track. - videoMkv = preparedVideo; - setStep(S_FINAL, { status: "done", progress: 100, detail: "Skipped — video encoding off" }); - } + }; - checkCancelled(); - setStep(S_AUDIO, { status: "active", progress: 0 }); - updateJob({ status: "encoding_audio" }); + const doAudio = async (): Promise => { + checkCancelled(); + setStep(S_AUDIO, { status: "active", progress: 0 }); + updateJob({ status: "encoding_audio" }); - const allAudioStreams = probe.audioStreams || []; + const allAudioStreams = probe.audioStreams || []; - const audioDetect = { - commentary: job.settings.detectCommentaryAudio, - descriptive: job.settings.detectDescriptiveAudio, - karaoke: job.settings.detectKaraokeAudio, - }; + if (opts.precomputed) { + // Preview: reuse the whole-source audio selection so every clip keeps identical ordering/naming. + audioStreams = opts.precomputed.audioStreams; + } else { + const allowedAudioLangs = job.settings.audioLanguages || []; + const langFiltered = filterStreamsByLanguage(allAudioStreams, allowedAudioLangs, "audio"); + const skippedLang = allAudioStreams.length - langFiltered.length; + if (skippedLang > 0) Logger.info(`[audio] Filtered ${skippedLang} track(s) not in [${allowedAudioLangs.join(", ")}]`); - let audioStreams: AudioStreamInfo[]; - if (opts.precomputed) { - // Preview: reuse the whole-source audio selection so every clip keeps identical ordering/naming. - audioStreams = opts.precomputed.audioStreams; - } else { - const allowedAudioLangs = job.settings.audioLanguages || []; - const langFiltered = filterStreamsByLanguage(allAudioStreams, allowedAudioLangs, "audio"); - const skippedLang = allAudioStreams.length - langFiltered.length; - if (skippedLang > 0) Logger.info(`[audio] Filtered ${skippedLang} track(s) not in [${allowedAudioLangs.join(", ")}]`); - - const typeFiltered = filterAudioTypes( - langFiltered, - { - removeCommentary: job.settings.removeCommentaryAudio, - removeDescriptive: job.settings.removeDescriptiveAudio, - removeKaraoke: job.settings.removeKaraokeAudio, - dropCompatibility: job.settings.dropCompatibilityAudio, - }, - audioDetect, - ); - const droppedByType = langFiltered.length - typeFiltered.length; - if (droppedByType > 0) Logger.info(`[audio] Dropped ${droppedByType} track(s) by type/compatibility filters`); + const typeFiltered = filterAudioTypes( + langFiltered, + { + removeCommentary: job.settings.removeCommentaryAudio, + removeDescriptive: job.settings.removeDescriptiveAudio, + removeKaraoke: job.settings.removeKaraokeAudio, + dropCompatibility: job.settings.dropCompatibilityAudio, + }, + audioDetect, + ); + const droppedByType = langFiltered.length - typeFiltered.length; + if (droppedByType > 0) Logger.info(`[audio] Dropped ${droppedByType} track(s) by type/compatibility filters`); - const sorted = sortAudioStreams(typeFiltered, { - languagePriority: job.settings.audioLanguagePriority, - preferUncensored: job.settings.preferUncensoredAudio, - detect: audioDetect, - }); + const sorted = sortAudioStreams(typeFiltered, { + languagePriority: job.settings.audioLanguagePriority, + preferUncensored: job.settings.preferUncensoredAudio, + detect: audioDetect, + }); - audioStreams = job.settings.dedupeAudio - ? deduplicateAudioStreams(sorted, { - collapseChannels: job.settings.keepBestAudioChannelsOnly, - codecPriority: job.settings.audioCodecPriority, - preferUncensored: job.settings.preferUncensoredAudio, - detect: audioDetect, - }) - : sorted; - - if (job.settings.dedupeAudio && sorted.length !== audioStreams.length) { - Logger.info(`[audio] Deduplicated ${sorted.length - audioStreams.length} redundant track(s)`); + audioStreams = job.settings.dedupeAudio + ? deduplicateAudioStreams(sorted, { + collapseChannels: job.settings.keepBestAudioChannelsOnly, + codecPriority: job.settings.audioCodecPriority, + preferUncensored: job.settings.preferUncensoredAudio, + detect: audioDetect, + }) + : sorted; + + if (job.settings.dedupeAudio && sorted.length !== audioStreams.length) { + Logger.info(`[audio] Deduplicated ${sorted.length - audioStreams.length} redundant track(s)`); + } } - } - const sortedTypes = audioStreams.map((s) => `${s.language || "und"}:${detectAudioTrackType(s, audioDetect)}:${s.channels || "?"}ch`); - Logger.info(`[audio] Track order: ${sortedTypes.join(", ")}`); + const sortedTypes = audioStreams.map((s) => `${s.language || "und"}:${detectAudioTrackType(s, audioDetect)}:${s.channels || "?"}ch`); + Logger.info(`[audio] Track order: ${sortedTypes.join(", ")}`); - const encodedAudioFiles: string[] = []; + if (skipAudioEncode) { + setStep(S_AUDIO, { + status: "done", + progress: 100, + detail: "Skipped — audio copied from source", + }); + } else if (audioStreams.length === 0) { + setStep(S_AUDIO, { status: "done", progress: 100, detail: "No audio streams" }); + } else { + setStep(S_AUDIO, { progress: 5, detail: `Extracting ${audioStreams.length} audio stream(s)` }); + + interface AudioEncodeJob { + index: number; + flacFile: string; + opusFile: string; + bitrate: number; + copy: boolean; + } - if (skipAudioEncode) { - setStep(S_AUDIO, { - status: "done", - progress: 100, - detail: "Skipped — audio copied from source", - }); - } else if (audioStreams.length === 0) { - setStep(S_AUDIO, { status: "done", progress: 100, detail: "No audio streams" }); - } else { - setStep(S_AUDIO, { progress: 5, detail: `Extracting ${audioStreams.length} audio stream(s)` }); - - interface AudioEncodeJob { - index: number; - flacFile: string; - opusFile: string; - bitrate: number; - copy: boolean; - } + const audioJobs: AudioEncodeJob[] = []; - const audioJobs: AudioEncodeJob[] = []; + for (let i = 0; i < audioStreams.length; i++) { + checkCancelled(); - for (let i = 0; i < audioStreams.length; i++) { - checkCancelled(); + const stream = audioStreams[i]!; + const flacFile = join(tempDir, `audio_${i}.flac`); + const opusFile = join(tempDir, `audio_${i}.opus`); + encodedAudioFiles.push(opusFile); + + const layout = normalizeLayout(stream.channelLayout); + const bitrate = getOpusBitrateForLayout(layout, job.settings.audioBitrates); + + const delayMs = stream.delayMs; + const delaySec = delayMs / 1000; + + const isOpusSource = (stream.codec || "").toLowerCase() === "opus"; + const sourceKbps = stream.bitrate ? stream.bitrate / 1000 : undefined; + const canCopy = isOpusSource && delayMs === 0 && sourceKbps !== undefined && sourceKbps <= bitrate; + + if (canCopy) { + const copyArgs = [ + "ffmpeg", + "-y", + "-i", + job.inputPath, + "-map", + `0:${stream.index}`, + "-vn", + "-sn", + "-dn", + "-map_metadata", + "-1", + "-map_chapters", + "-1", + "-c:a", + "copy", + opusFile, + ]; + const copyRes = await run(copyArgs, { signal: stageSignal }); + if (copyRes.code !== 0) { + throw new Error(`FFmpeg audio copy failed for stream ${i}: ${copyRes.stderr || copyRes.stdout}`); + } - const stream = audioStreams[i]!; - const flacFile = join(tempDir, `audio_${i}.flac`); - const opusFile = join(tempDir, `audio_${i}.opus`); - encodedAudioFiles.push(opusFile); + audioJobs.push({ index: i, flacFile, opusFile, bitrate, copy: true }); + Logger.info(`[audio] Stream ${i} already Opus @ ~${Math.round(sourceKbps)}kbps (<= ${bitrate}kbps target) — copying without re-encode`); + + setStep(S_AUDIO, { + progress: 5 + Math.round(((i + 1) / audioStreams.length) * 35), + detail: `Copying audio (${i + 1}/${audioStreams.length})`, + }); + continue; + } - const layout = normalizeLayout(stream.channelLayout); - const bitrate = getOpusBitrateForLayout(layout, job.settings.audioBitrates); + const ffArgs = ["ffmpeg", "-y", "-i", job.inputPath, "-map", `0:${stream.index}`, "-vn", "-sn", "-dn", "-c:a", "flac"]; - const delayMs = stream.delayMs; - const delaySec = delayMs / 1000; + if (delaySec < 0) { + ffArgs.push("-af", `atrim=start=${Math.abs(delaySec)}`); + } else if (delaySec > 0) { + ffArgs.push("-af", `adelay=${delayMs}:all=1`); + } - const isOpusSource = (stream.codec || "").toLowerCase() === "opus"; - const sourceKbps = stream.bitrate ? stream.bitrate / 1000 : undefined; - const canCopy = isOpusSource && delayMs === 0 && sourceKbps !== undefined && sourceKbps <= bitrate; + ffArgs.push(flacFile); - if (canCopy) { - const copyArgs = [ - "ffmpeg", - "-y", - "-i", - job.inputPath, - "-map", - `0:${stream.index}`, - "-vn", - "-sn", - "-dn", - "-map_metadata", - "-1", - "-map_chapters", - "-1", - "-c:a", - "copy", - opusFile, - ]; - const copyRes = await run(copyArgs, { signal }); - if (copyRes.code !== 0) { - throw new Error(`FFmpeg audio copy failed for stream ${i}: ${copyRes.stderr || copyRes.stdout}`); + const ffRes = await run(ffArgs, { signal: stageSignal }); + if (ffRes.code !== 0) { + throw new Error(`FFmpeg audio extraction failed for stream ${i}: ${ffRes.stderr || ffRes.stdout}`); } - audioJobs.push({ index: i, flacFile, opusFile, bitrate, copy: true }); - Logger.info(`[audio] Stream ${i} already Opus @ ~${Math.round(sourceKbps)}kbps (<= ${bitrate}kbps target) — copying without re-encode`); + audioJobs.push({ index: i, flacFile, opusFile, bitrate, copy: false }); setStep(S_AUDIO, { progress: 5 + Math.round(((i + 1) / audioStreams.length) * 35), - detail: `Copying audio (${i + 1}/${audioStreams.length})`, + detail: `Extracting audio (${i + 1}/${audioStreams.length})`, }); - continue; - } - - const ffArgs = ["ffmpeg", "-y", "-i", job.inputPath, "-map", `0:${stream.index}`, "-vn", "-sn", "-dn", "-c:a", "flac"]; - - if (delaySec < 0) { - ffArgs.push("-af", `atrim=start=${Math.abs(delaySec)}`); - } else if (delaySec > 0) { - ffArgs.push("-af", `adelay=${delayMs}:all=1`); } - ffArgs.push(flacFile); + setStep(S_AUDIO, { progress: 40, detail: `Encoding ${audioJobs.length} audio stream(s)` }); - const ffRes = await run(ffArgs, { signal }); - if (ffRes.code !== 0) { - throw new Error(`FFmpeg audio extraction failed for stream ${i}: ${ffRes.stderr || ffRes.stdout}`); - } + const concurrency = Math.max(1, Math.min(audioJobs.length, cpus().length)); + let nextJob = 0; + let completed = 0; + let failed = false; - audioJobs.push({ index: i, flacFile, opusFile, bitrate, copy: false }); + const encodeWorker = async (): Promise => { + while (!failed) { + const jobIdx = nextJob++; + if (jobIdx >= audioJobs.length) return; - setStep(S_AUDIO, { - progress: 5 + Math.round(((i + 1) / audioStreams.length) * 35), - detail: `Extracting audio (${i + 1}/${audioStreams.length})`, - }); - } - - setStep(S_AUDIO, { progress: 40, detail: `Encoding ${audioJobs.length} audio stream(s)` }); + try { + checkCancelled(); - const concurrency = Math.max(1, Math.min(audioJobs.length, cpus().length)); - let nextJob = 0; - let completed = 0; - let failed = false; + const aj = audioJobs[jobIdx]!; - const encodeWorker = async (): Promise => { - while (!failed) { - const jobIdx = nextJob++; - if (jobIdx >= audioJobs.length) return; + if (aj.copy) { + completed++; + setStep(S_AUDIO, { + progress: 40 + Math.round((completed / audioJobs.length) * 60), + detail: `Encoding audio (${completed}/${audioJobs.length})`, + }); + continue; + } - try { - checkCancelled(); + const opusArgs = ["opusenc", "--bitrate", String(aj.bitrate)]; + if (job.settings.noPhaseInv) { + opusArgs.push("--no-phase-inv"); + } + opusArgs.push("--discard-comments"); + opusArgs.push("--discard-pictures"); + opusArgs.push(aj.flacFile, aj.opusFile); - const aj = audioJobs[jobIdx]!; + const opusRes = await run(opusArgs, { signal: stageSignal }); + if (opusRes.code !== 0) { + throw new Error(`Audio encoding failed for stream ${aj.index}: ${opusRes.stderr || opusRes.stdout}`); + } - if (aj.copy) { completed++; setStep(S_AUDIO, { progress: 40 + Math.round((completed / audioJobs.length) * 60), detail: `Encoding audio (${completed}/${audioJobs.length})`, }); - continue; + } catch (err) { + failed = true; + throw err; } + } + }; - const opusArgs = ["opusenc", "--bitrate", String(aj.bitrate)]; - if (job.settings.noPhaseInv) { - opusArgs.push("--no-phase-inv"); - } - opusArgs.push("--discard-comments"); - opusArgs.push("--discard-pictures"); - opusArgs.push(aj.flacFile, aj.opusFile); + await Promise.all(Array.from({ length: concurrency }, () => encodeWorker())); - const opusRes = await run(opusArgs, { signal }); - if (opusRes.code !== 0) { - throw new Error(`Audio encoding failed for stream ${aj.index}: ${opusRes.stderr || opusRes.stdout}`); - } + setStep(S_AUDIO, { status: "done", progress: 100 }); + } + }; - completed++; - setStep(S_AUDIO, { - progress: 40 + Math.round((completed / audioJobs.length) * 60), - detail: `Encoding audio (${completed}/${audioJobs.length})`, - }); - } catch (err) { - failed = true; - throw err; - } + const doSubtitleAnalysis = async (): Promise => { + if (!skipSubtitleProcessing) { + if (opts.precomputed) { + subtitleStreams = opts.precomputed.subtitleStreams; + } else { + const allSubtitleStreams = probe.subtitleStreams || []; + await analyzeSubtitleStreams( + allSubtitleStreams, + job.inputPath, + tempDir, + { + langDetect: job.settings.subtitleLangDetect, + langDetectConfidence: job.settings.subtitleLangDetectConfidence, + detectSignsSongs: job.settings.detectSignsSongs, + detectSDH: job.settings.detectSDH, + detectHonorifics: job.settings.detectHonorifics, + signsSongsStyleRatio: job.settings.signsSongsStyleRatio, + signsSongsLineRatio: job.settings.signsSongsLineRatio, + sdhRatioThreshold: job.settings.sdhRatioThreshold, + sdhMinLines: job.settings.sdhMinLines, + honorificsMinCount: job.settings.honorificsMinCount, + honorificsRatio: job.settings.honorificsRatio, + assumeMislabeled: job.settings.assumeMislabeledTracks, + }, + stageSignal, + ); + + const sortedSubtitleStreams = sortSubtitleStreams(allSubtitleStreams, { + sourcePriority: job.settings.subtitleSourcePriority, + fansubTiebreak: job.settings.subtitleFansubTiebreak, + formatPriority: job.settings.subtitleFormatPriority, + languagePriority: job.settings.subtitleLanguagePriority, + }); + const allowedSubLangs = job.settings.subtitleLanguages || []; + const langFilteredSubs = filterStreamsByLanguage(sortedSubtitleStreams, allowedSubLangs, "subtitle"); + const typeFilteredSubs = filterSubtitleTypes(langFilteredSubs, { + removeSDH: job.settings.removeSDHSubtitles, + removeCommentary: job.settings.removeCommentarySubtitles, + removeForcedSignsSongs: job.settings.removeForcedSignsSongs, + removeStoryboard: job.settings.removeStoryboardSubtitles, + removeHonorifics: job.settings.removeHonorificsSubtitles, + dropPicture: job.settings.dropPictureSubtitles, + }); + subtitleStreams = job.settings.dedupeSubtitles + ? deduplicateSubtitleStreams(typeFilteredSubs, { acrossFormat: job.settings.dedupeAcrossFormat }) + : typeFilteredSubs; } - }; - - await Promise.all(Array.from({ length: concurrency }, () => encodeWorker())); - - setStep(S_AUDIO, { status: "done", progress: 100 }); - } + } + }; - let subtitleStreams: SubtitleStreamInfo[] = []; - if (!skipSubtitleProcessing) { - if (opts.precomputed) { - subtitleStreams = opts.precomputed.subtitleStreams; + const doTranslate = async (): Promise => { + checkCancelled(); + setStep(S_TRANSLATE, { status: "active", progress: 0 }); + if (!skipSubtitleProcessing && job.settings.translateSubtitles && !previewMode && subtitleStreams.length > 0) { + try { + translatedTracks = await runTranslateStep({ + subtitleStreams, + inputPath: job.inputPath, + tempDir, + settings: job.settings, + subtitleStyle: { ...DEFAULT_STYLE_APPEARANCE, fontName: job.settings.fontGroup }, + organization: config.organization, + signal: stageSignal, + onProgress: ({ done, total }) => { + const overall = total > 0 ? Math.round((done / total) * 100) : 0; + setStep(S_TRANSLATE, { progress: overall, detail: `Translating ${done}/${total} lines` }); + }, + }); + setStep(S_TRANSLATE, { + status: "done", + progress: 100, + detail: translatedTracks.length ? `Added ${translatedTracks.length} track(s)` : "Nothing to translate", + }); + } catch (err) { + setStep(S_TRANSLATE, { status: "error", progress: 100 }); + throw err; + } } else { - const allSubtitleStreams = probe.subtitleStreams || []; - await analyzeSubtitleStreams( - allSubtitleStreams, - job.inputPath, - tempDir, - { - langDetect: job.settings.subtitleLangDetect, - langDetectConfidence: job.settings.subtitleLangDetectConfidence, - detectSignsSongs: job.settings.detectSignsSongs, - detectSDH: job.settings.detectSDH, - detectHonorifics: job.settings.detectHonorifics, - signsSongsStyleRatio: job.settings.signsSongsStyleRatio, - signsSongsLineRatio: job.settings.signsSongsLineRatio, - sdhRatioThreshold: job.settings.sdhRatioThreshold, - sdhMinLines: job.settings.sdhMinLines, - honorificsMinCount: job.settings.honorificsMinCount, - honorificsRatio: job.settings.honorificsRatio, - assumeMislabeled: job.settings.assumeMislabeledTracks, - }, - signal, - ); - - const sortedSubtitleStreams = sortSubtitleStreams(allSubtitleStreams, { - sourcePriority: job.settings.subtitleSourcePriority, - fansubTiebreak: job.settings.subtitleFansubTiebreak, - formatPriority: job.settings.subtitleFormatPriority, - languagePriority: job.settings.subtitleLanguagePriority, - }); - const allowedSubLangs = job.settings.subtitleLanguages || []; - const langFilteredSubs = filterStreamsByLanguage(sortedSubtitleStreams, allowedSubLangs, "subtitle"); - const typeFilteredSubs = filterSubtitleTypes(langFilteredSubs, { - removeSDH: job.settings.removeSDHSubtitles, - removeCommentary: job.settings.removeCommentarySubtitles, - removeForcedSignsSongs: job.settings.removeForcedSignsSongs, - removeStoryboard: job.settings.removeStoryboardSubtitles, - removeHonorifics: job.settings.removeHonorificsSubtitles, - dropPicture: job.settings.dropPictureSubtitles, - }); - subtitleStreams = job.settings.dedupeSubtitles - ? deduplicateSubtitleStreams(typeFilteredSubs, { acrossFormat: job.settings.dedupeAcrossFormat }) - : typeFilteredSubs; + setStep(S_TRANSLATE, { status: "done", progress: 100, detail: "Skipped" }); } - } + }; - checkCancelled(); - setStep(S_TRANSLATE, { status: "active", progress: 0 }); - let translatedTracks: TranslatedTrack[] = []; - if (!skipSubtitleProcessing && job.settings.translateSubtitles && !previewMode && subtitleStreams.length > 0) { - try { - translatedTracks = await runTranslateStep({ - subtitleStreams, - inputPath: job.inputPath, - tempDir, - settings: job.settings, - subtitleStyle: { ...DEFAULT_STYLE_APPEARANCE, fontName: job.settings.fontGroup }, - organization: config.organization, - signal, - onProgress: ({ lang, langIndex, langCount, done, total }) => { - const frac = total > 0 ? done / total : 1; - const overall = Math.round(((langIndex + frac) / Math.max(1, langCount)) * 100); - setStep(S_TRANSLATE, { progress: overall, detail: `Translating ${lang}: ${done}/${total}` }); - }, - }); - setStep(S_TRANSLATE, { - status: "done", - progress: 100, - detail: translatedTracks.length ? `Added ${translatedTracks.length} track(s)` : "Nothing to translate", - }); - } catch (err) { - setStep(S_TRANSLATE, { status: "error", progress: 100 }); - throw err; // fail the job with a clear message (Ollama unreachable / model missing) + try { + if (overlapTranslate) { + // Audio + subtitle analysis + translation all overlap the encode. + const subsThenTranslate = doSubtitleAnalysis().then(doTranslate); + await Promise.all([encodeVideo(), doAudio(), subsThenTranslate]); + } else { + // Local Ollama: still overlap audio + subtitle analysis (both cheap and + // source-only), but keep the heavy translation off the encode. + await Promise.all([encodeVideo(), doAudio(), doSubtitleAnalysis()]); + await doTranslate(); } - } else { - setStep(S_TRANSLATE, { status: "done", progress: 100, detail: "Skipped" }); + } catch (err) { + stageAbort.abort(err); + throw err; + } finally { + signal?.removeEventListener("abort", onOuterAbort); } checkCancelled(); @@ -1386,7 +1421,7 @@ export async function encodeJob( Logger.info(`[mux] Preserving display aspect ratio: ${probe.displayAspectRatio}`); } - mkvArgs.push(videoMkv); + mkvArgs.push(videoMkv!); if (!skipAudioEncode) { // Audio tracks diff --git a/src/subtitle-translate.ts b/src/subtitle-translate.ts index 082101f..8e8bea6 100644 --- a/src/subtitle-translate.ts +++ b/src/subtitle-translate.ts @@ -3,6 +3,7 @@ import { parseAssEvents, splitAssText, joinAssText, buildTranslatedAss, type Ass import { parseSrt, buildSrt } from "./srt-edit"; import { translateBatch, type OllamaOptions } from "./ollama"; import { resolveTranslateLang, normalizeTag, type TranslateLang } from "./translate-languages"; +import { createSemaphore, type Semaphore } from "./concurrency"; /** * Split a timed line sequence into translation chunks of roughly `batchSize`, @@ -62,6 +63,8 @@ export function planChunks(startsMs: number[], endsMs: number[], batchSize: numb export interface TranslateContentOptions { format: "ass" | "srt"; batchSize: number; + /** Shared Ollama-request budget. If omitted, chunks run sequentially (limit 1). */ + sem?: Semaphore; /** When false, only dialogue-classified ASS lines are translated. */ translateSignsSongs: boolean; /** @@ -104,20 +107,24 @@ async function translateUnits(units: Unit[], opts: TranslateContentOptions): Pro const ends = units.map((u) => u.endMs); const chunks = planChunks(starts, ends, opts.batchSize); - let done = 0; const total = units.length; + let done = 0; opts.onProgress?.(0, total); - for (const [lo, hi] of chunks) { - const slice = units.slice(lo, hi); - const visibles = slice.map((u) => u.visible); - const translated = await translateBatch(visibles, opts.ollama); - for (let k = 0; k < slice.length; k++) { - out.set(slice[k]!.key, translated[k]!); - } - done += slice.length; - opts.onProgress?.(done, total); - } + const sem = opts.sem ?? createSemaphore(1); + + await Promise.all( + chunks.map(async ([lo, hi]) => { + const slice = units.slice(lo, hi); + const visibles = slice.map((u) => u.visible); + const translated = await sem.run(() => translateBatch(visibles, opts.ollama)); + for (let k = 0; k < slice.length; k++) { + out.set(slice[k]!.key, translated[k]!); + } + done += slice.length; // safe: runs synchronously between awaits + opts.onProgress?.(done, total); + }), + ); return out; } diff --git a/src/translate-step.ts b/src/translate-step.ts index c7a64b2..5cad1ca 100644 --- a/src/translate-step.ts +++ b/src/translate-step.ts @@ -8,6 +8,7 @@ import { dialogueStyleNames } from "./ass-classifier"; import { styleSrtAss, restyleAssDialogueFont } from "./ass-style"; import { checkOllama, type OllamaOptions } from "./ollama"; import { planTargetLanguages, translateSubtitleContent, type KeptSubDescriptor } from "./subtitle-translate"; +import { createSemaphore } from "./concurrency"; /** * A finished, on-disk translated subtitle ready to hand to mkvmerge. Shaped to @@ -218,46 +219,71 @@ export async function runTranslateStep(params: RunTranslateStepParams): Promise< const dialogueStyles = format === "ass" ? dialogueStyleNames(styledSource) : new Set(); - const out: TranslatedTrack[] = []; + const out: TranslatedTrack[] = new Array(plan.productions.length); const langCount = plan.productions.length; - for (let li = 0; li < plan.productions.length; li++) { - const prod = plan.productions[li]!; - const ollama: OllamaOptions = { - url: settings.translateOllamaUrl, - model: settings.translateModel, - source: prod.source, - target: prod.target, - numCtx: settings.translateNumCtx, - timeoutMs: settings.translateTimeoutMs, - signal, - }; - - const translated = await translateSubtitleContent(styledSource, { - format, - batchSize: settings.translateBatchSize, - translateSignsSongs: settings.translateSignsSongs, - isDialogueStyle: (style) => dialogueStyles.has(style), - ollama, - onProgress: (done, total) => onProgress?.({ lang: prod.target.name, langIndex: li, langCount, done, total }), - }); - - const ext = format === "ass" ? "ass" : "srt"; - const file = join(tempDir, `translated_${prod.targetTag}_${sourceStream.index}.${ext}`); - writeFileSync(file, translated, "utf-8"); - - out.push({ - file, - language: prod.targetTag, - trackName: buildSubtitleTrackName(prod.trackType, undefined, organization), - trackType: prod.trackType, - flagArgs: computeTranslatedFlagArgs(prod.trackType), - format, - sourceIndex: sourceStream.index, - }); - - Logger.info(`[translate] Produced ${prod.target.name} (${prod.trackType}) from track ${sourceStream.index}`); - } + // ONE budget for every in-flight Ollama request - across all target languages + // AND all chunks within each language. Languages and chunks fan out freely; + // this cap keeps total requests <= what the server can serve in parallel. + // Keep it <= the Ollama server's OLLAMA_NUM_PARALLEL. + const sem = createSemaphore(Math.max(1, settings.translateConcurrency ?? 1)); + + // Aggregate progress across concurrently-running languages. + const perLangDone = new Array(langCount).fill(0); + const perLangTotal = new Array(langCount).fill(0); + const emitProgress = (li: number, lang: string) => { + let done = 0; + let total = 0; + for (let i = 0; i < langCount; i++) { + done += perLangDone[i]!; + total += perLangTotal[i]!; + } + onProgress?.({ lang, langIndex: li, langCount, done, total }); + }; + + await Promise.all( + plan.productions.map(async (prod, li) => { + const ollama: OllamaOptions = { + url: settings.translateOllamaUrl, + model: settings.translateModel, + source: prod.source, + target: prod.target, + numCtx: settings.translateNumCtx, + timeoutMs: settings.translateTimeoutMs, + signal, + }; + + const translated = await translateSubtitleContent(styledSource, { + format, + batchSize: settings.translateBatchSize, + translateSignsSongs: settings.translateSignsSongs, + isDialogueStyle: (style) => dialogueStyles.has(style), + ollama, + sem, + onProgress: (done, total) => { + perLangDone[li] = done; + perLangTotal[li] = total; + emitProgress(li, prod.target.name); + }, + }); + + const ext = format === "ass" ? "ass" : "srt"; + const file = join(tempDir, `translated_${prod.targetTag}_${sourceStream.index}.${ext}`); + writeFileSync(file, translated, "utf-8"); + + out[li] = { + file, + language: prod.targetTag, + trackName: buildSubtitleTrackName(prod.trackType, undefined, organization), + trackType: prod.trackType, + flagArgs: computeTranslatedFlagArgs(prod.trackType), + format, + sourceIndex: sourceStream.index, + }; + + Logger.info(`[translate] Produced ${prod.target.name} (${prod.trackType}) from track ${sourceStream.index}`); + }), + ); return out; } diff --git a/src/types.ts b/src/types.ts index 95d6f94..733824e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -238,6 +238,10 @@ export interface JobSettings { translateNumCtx: number; /** Per-request timeout, ms. */ translateTimeoutMs: number; + /** Max concurrent in-flight Ollama requests (all languages + chunks). Keep <= server OLLAMA_NUM_PARALLEL. Default 1. */ + translateConcurrency?: number; + /** May translation overlap the video encode? "auto" overlaps only when Ollama is NOT on a loopback address. Default "auto". */ + translateDuringEncode?: "auto" | "always" | "never"; /** * Ordered list of VapourSynth filter passes to apply during the prepare * stage, before the FFmpeg -vf chain. Each entry references a preset by From 47bc59b3ad65c46fff32fb6e67b3c8c986511306 Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Sat, 4 Jul 2026 06:23:16 +0200 Subject: [PATCH 03/13] Start with ollama generic LLM implementation --- public/api/client.ts | 3 +- public/config/options.ts | 1 + public/features/settings-form.ts | 8 +- public/index.html | 8 + public/types.ts | 1 + src/ass-edit.ts | 5 + src/config.ts | 1 + src/index.ts | 14 +- src/ollama-generic.ts | 295 +++++++++++++++++++++++++++++++ src/settings-code.ts | 1 + src/store.ts | 1 + src/subtitle-translate.ts | 43 +++-- src/translate-step.ts | 1 + src/types.ts | 1 + tests/ollama-generic.test.ts | 72 ++++++++ 15 files changed, 438 insertions(+), 17 deletions(-) create mode 100644 src/ollama-generic.ts create mode 100644 tests/ollama-generic.test.ts diff --git a/public/api/client.ts b/public/api/client.ts index f76e06f..5781582 100644 --- a/public/api/client.ts +++ b/public/api/client.ts @@ -398,12 +398,13 @@ export async function testTranslateConnection( url: string, model: string, target?: string, + strategy: "translategemma" | "generic" = "translategemma", ): Promise<{ ok: boolean; error?: string; sample?: string; target?: string }> { try { const res = await authFetch(`${API}/api/translate/test`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url, model, target }), + body: JSON.stringify({ url, model, target, strategy }), }); return await res.json(); } catch (err: any) { diff --git a/public/config/options.ts b/public/config/options.ts index c2ba5f1..aafb9db 100644 --- a/public/config/options.ts +++ b/public/config/options.ts @@ -86,6 +86,7 @@ export const PIPELINE_PRESET_HELP: Record = { custom: "Configure each pipeline stage individually below.", }; +export const TRANSLATE_STRATEGIES = ["translategemma", "generic"]; export const TRANSLATE_MODEL_OPTIONS = ["translategemma:4b", "translategemma:12b", "translategemma:27b"]; export const DEFAULT_STYLE_APPEARANCE: StyleAppearance = { diff --git a/public/features/settings-form.ts b/public/features/settings-form.ts index 4e4ee12..965553a 100644 --- a/public/features/settings-form.ts +++ b/public/features/settings-form.ts @@ -18,6 +18,7 @@ import { SUBTITLE_PROCESSING_OPTIONS, SUBTITLE_SOURCE_PRIORITY_OPTIONS, TRANSLATE_MODEL_OPTIONS, + TRANSLATE_STRATEGIES, VIDEO_ENCODE_OPTIONS, } from "../config/options"; import { @@ -347,7 +348,8 @@ export function renderSettingsForm(prefix: SettingsFormPrefix, settings: JobSett testResult.textContent = "Testing…"; testResult.className = "test-result"; const firstTarget = settings.translateTargetLanguages[0]; - const r = await testTranslateConnection(settings.translateOllamaUrl, settings.translateModel, firstTarget); + const strategy = settings.translateStrategy || "translategemma"; + const r = await testTranslateConnection(settings.translateOllamaUrl, settings.translateModel, firstTarget, strategy); testBtn.disabled = false; if (r.ok) { testResult.textContent = `✓ OK - sample (${r.target}): "${r.sample}"`; @@ -357,4 +359,8 @@ export function renderSettingsForm(prefix: SettingsFormPrefix, settings: JobSett testResult.classList.add("error"); } }); + + renderRadioPills(el("translate-strategy"), TRANSLATE_STRATEGIES, settings.translateStrategy, (v) => { + settings.translateStrategy = v as "translategemma" | "generic"; + }); } diff --git a/public/index.html b/public/index.html index ca2364b..0156b59 100644 --- a/public/index.html +++ b/public/index.html @@ -282,6 +282,10 @@

Default Settings

Subtitle translation
+
+ +
+
@@ -534,6 +538,10 @@

Job Settings

Subtitle translation
+
+ +
+
diff --git a/public/types.ts b/public/types.ts index a4d1010..916c091 100644 --- a/public/types.ts +++ b/public/types.ts @@ -217,6 +217,7 @@ export interface JobSettings { translateConcurrency?: number; /** May translation overlap the video encode? "auto" overlaps only when Ollama is NOT on a loopback address. Default "auto". */ translateDuringEncode?: "auto" | "always" | "never"; + translateStrategy: "translategemma" | "generic"; /** * Ordered list of VapourSynth filter passes to apply during the prepare * stage, before the FFmpeg -vf chain. Each entry references a preset by diff --git a/src/ass-edit.ts b/src/ass-edit.ts index 74b5659..05d2fde 100644 --- a/src/ass-edit.ts +++ b/src/ass-edit.ts @@ -30,6 +30,7 @@ export interface AssEventLine { * with a (possibly new) text to rebuild the line. */ prefix: string; + name: string; // ASS "Name"/"Actor" column ("" when absent). Context only } export interface ParsedAssEvents { @@ -65,6 +66,7 @@ export function parseAssEvents(assText: string): ParsedAssEvents { let idxEnd = -1; let idxStyle = -1; let idxText = -1; + let idxName = -1; for (let lineNo = 0; lineNo < lines.length; lineNo++) { const raw = lines[lineNo]!; @@ -88,6 +90,8 @@ export function parseAssEvents(assText: string): ParsedAssEvents { idxEnd = eventKeys.indexOf("end"); idxStyle = eventKeys.indexOf("style"); idxText = eventKeys.indexOf("text"); + idxName = eventKeys.indexOf("name"); + if (idxName < 0) idxName = eventKeys.indexOf("actor"); continue; } @@ -125,6 +129,7 @@ export function parseAssEvents(assText: string): ParsedAssEvents { endMs: assTimeToMs(fields[idxEnd] ?? ""), rawText, prefix, + name: idxName >= 0 ? (fields[idxName] ?? "").trim() : "", }); } diff --git a/src/config.ts b/src/config.ts index 58e5b17..0c43bbc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -90,6 +90,7 @@ const DEFAULT_JOB_SETTINGS: JobSettings = { translateSignsSongs: true, translateNumCtx: 8192, translateTimeoutMs: 120000, + translateStrategy: "translategemma", vsFilters: [], }; diff --git a/src/index.ts b/src/index.ts index bbc249f..8d46ef8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,7 @@ import type { GroupStyleConfig } from "./subtitle-style"; import { isInsideRoots, listSystemFonts } from "./system-fonts"; import { checkOllama, translateOne } from "./ollama"; import { resolveTranslateLang } from "./translate-languages"; +import { checkGenericModel } from "./ollama-generic"; export const config = await loadConfig(); @@ -388,7 +389,7 @@ app.post("/api/config/import-code", async (c) => { }); app.post("/api/translate/test", async (c) => { - const body = (await c.req.json().catch(() => ({}))) as { url?: string; model?: string; target?: string }; + const body = (await c.req.json().catch(() => ({}))) as { url?: string; strategy?: string; model?: string; target?: string }; const url = (body.url || "").trim(); const model = (body.model || "").trim(); if (!url || !model) return c.json({ ok: false, error: "Missing Ollama URL or model" }, 400); @@ -396,13 +397,20 @@ app.post("/api/translate/test", async (c) => { const health = await checkOllama(url, model); if (!health.ok) return c.json({ ok: false, error: health.detail }); - // Prove the model actually generates: translate a tiny sample. + const strategy = body.strategy === "generic" ? "generic" : "translategemma"; + const source = { name: "English", code: "en" }; const target = resolveTranslateLang(body.target || "slv") ?? { name: "Slovenian", code: "sl" }; + + if (strategy === "generic") { + const r = await checkGenericModel(url, model, source, target); + return c.json(r.ok ? { ok: true, sample: r.sample, model, target: target.name } : { ok: false, error: r.detail }); + } + try { const sample = await translateOne("The goal of all life is death.", { url, model, - source: { name: "English", code: "en" }, + source, target, timeoutMs: 30000, }); diff --git a/src/ollama-generic.ts b/src/ollama-generic.ts new file mode 100644 index 0000000..84d2364 --- /dev/null +++ b/src/ollama-generic.ts @@ -0,0 +1,295 @@ +import { Logger } from "./logger"; +import type { TranslateLang } from "./translate-languages"; + +/** + * Generic instruct-model translator for Ollama (Qwen, Llama, Mistral, Gemma-it, + * etc.). Unlike TranslateGemma — a translation-only model that emits just the + * translation — general chat models follow rich instructions and use context, + * but they also tend to add commentary and can merge/split/reorder lines. + * + * To make batched translation robust we use structured JSON keyed by stable + * IDs: we send `[{id, name?, text}]` and expect `[{id, text}]` back, then remap + * by id. Because alignment is by id (not position), the model reordering the + * array can't corrupt cue alignment. Any id the model drops is recovered with a + * per-line fallback, so a batch can never silently lose or misalign a cue. + * + * The ASS "Name" (actor) field rides along as `name` so the model knows which + * character is speaking and can keep tone and forms of address consistent. + */ + +export interface TranslateItem { + /** + * Source text to translate. Already tag-split by the caller, so it may carry + * a leading `{\...}` override block and literal `\N` breaks — those are + * "protected" and must survive verbatim. + */ + text: string; + /** Speaking character, from the ASS Name/Actor field (absent for SRT). */ + name?: string; +} + +export interface GenericOptions { + /** Base URL, e.g. "http://localhost:11434". */ + url: string; + /** Model tag, e.g. "qwen2.5:14b", "llama3.1:8b". */ + model: string; + source: TranslateLang; + target: TranslateLang; + /** Ollama num_ctx. Batches of JSON need headroom; default 8192. */ + numCtx?: number; + /** Sampling temperature. A touch above deterministic for natural dialogue. */ + temperature?: number; + /** Per-request timeout in ms. */ + timeoutMs?: number; + /** External cancellation (job abort). */ + signal?: AbortSignal; + /** + * Optional override for the instruction block that precedes the JSON payload. + * `{target}` / `{source}` placeholders are substituted. When omitted, the + * built-in subtitle-translation instruction is used. + */ + instruction?: string; +} + +const DEFAULT_TIMEOUT_MS = 180_000; +const DEFAULT_NUM_CTX = 8192; +const DEFAULT_TEMPERATURE = 0.1; + +/** Payload row sent to the model. */ +interface PayloadRow { + id: string; + name?: string; + text: string; +} + +/** + * Build the instruction block. Based on a natural-dialogue subtitle prompt, + * adapted for id-keyed JSON I/O and the character-name context. + */ +export function buildGenericInstruction(source: TranslateLang, target: TranslateLang, override?: string): string { + if (override && override.trim()) { + return override.replaceAll("{target}", target.name).replaceAll("{source}", source.name).trim(); + } + return [ + `Translate these anime/movie/show subtitles from ${source.name} into ${target.name}.`, + `Produce natural dialogue that sounds as though it was originally written in ${target.name}.`, + `Translate the intended meaning rather than word-for-word or the source sentence structure.`, + ``, + `Requirements:`, + `- Preserve all meaning without additions or omissions.`, + `- Preserve each character's tone, humour and emotional intensity.`, + `- Use natural conversational language.`, + `- Maintain consistent names, terminology and forms of address across entries.`, + `- Each entry has an "id", an optional "name" (the speaking character), and "text".`, + ` Use "name" to know who is speaking and to keep their voice and forms of address consistent.`, + `- Use the surrounding entries as context for pronouns, tone and continuity.`, + `- Preserve every "id" exactly.`, + `- Preserve protected formatting verbatim: any leading "{...}" override block and every "\\N" line break must appear unchanged in the output text.`, + `- Do not merge, split, reorder, add or remove entries.`, + `- Return ONLY a JSON array of objects {"id": "...", "text": "..."}, one per input entry, with no commentary, no explanations and no markdown fences.`, + ].join("\n"); +} + +/** Build the full prompt: instruction block followed by the JSON payload. */ +export function buildGenericPrompt(items: TranslateItem[], opts: Pick): string { + const rows: PayloadRow[] = items.map((it, i) => { + const row: PayloadRow = { id: String(i), text: it.text }; + const name = it.name?.trim(); + if (name) row.name = name; + return row; + }); + const instruction = buildGenericInstruction(opts.source, opts.target, opts.instruction); + return `${instruction}\n\nInput:\n${JSON.stringify(rows, null, 0)}`; +} + +/** Strip leading/trailing markdown code fences if the model wrapped its answer. */ +export function stripCodeFences(s: string): string { + let t = s.trim(); + // ```json\n ... \n``` or ``` ... ``` + const fence = t.match(/^```[a-zA-Z]*\s*\n?([\s\S]*?)\n?```$/); + if (fence && fence[1] !== undefined) t = fence[1].trim(); + return t; +} + +/** Extract the outermost JSON array substring, tolerating prose around it. */ +export function extractJsonArray(s: string): string | null { + const t = stripCodeFences(s); + const start = t.indexOf("["); + const end = t.lastIndexOf("]"); + if (start < 0 || end <= start) return null; + return t.slice(start, end + 1); +} + +/** + * Parse a model response into a map id->text. Returns an array aligned to + * `count` (index === numeric id); entries the model dropped are `null`. + */ +export function parseGenericResponse(content: string, count: number): (string | null)[] { + const out: (string | null)[] = new Array(count).fill(null); + const json = extractJsonArray(content); + if (!json) return out; + + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return out; + } + if (!Array.isArray(parsed)) return out; + + for (const row of parsed) { + if (!row || typeof row !== "object") continue; + const r = row as Record; + const rawId = r["id"]; + const rawText = r["text"]; + if (rawText === undefined || rawText === null) continue; + const id = typeof rawId === "number" ? rawId : parseInt(String(rawId ?? ""), 10); + if (!Number.isInteger(id) || id < 0 || id >= count) continue; + out[id] = String(rawText); + } + return out; +} + +interface OllamaChatResponse { + message?: { content?: string }; + error?: string; +} + +/** One /api/chat round-trip. Throws on HTTP, network, timeout or abort. */ +async function chat(prompt: string, opts: GenericOptions): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(new Error("Ollama request timed out")), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const onExternalAbort = () => controller.abort(opts.signal?.reason ?? new Error("aborted")); + if (opts.signal) { + if (opts.signal.aborted) onExternalAbort(); + else opts.signal.addEventListener("abort", onExternalAbort, { once: true }); + } + try { + const base = opts.url.replace(/\/+$/, ""); + const res = await fetch(`${base}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + body: JSON.stringify({ + model: opts.model, + stream: false, + messages: [{ role: "user", content: prompt }], + options: { + temperature: opts.temperature ?? DEFAULT_TEMPERATURE, + num_ctx: opts.numCtx ?? DEFAULT_NUM_CTX, + }, + }), + }); + if (!res.ok) throw new Error(`Ollama /api/chat returned HTTP ${res.status}`); + const data = (await res.json()) as OllamaChatResponse; + if (data.error) throw new Error(`Ollama error: ${data.error}`); + return data.message?.content ?? ""; + } finally { + clearTimeout(timeout); + opts.signal?.removeEventListener("abort", onExternalAbort); + } +} + +/** + * Translate a single line with a minimal direct prompt. Used as the recovery + * path for any entry a batch dropped. Returns the source text unchanged only if + * the model yields nothing usable, so a cue is never lost. + */ +export async function translateOneGeneric(text: string, opts: GenericOptions): Promise { + if (text.trim() === "") return text; + const prompt = + `Translate this single ${opts.source.name} subtitle line into ${opts.target.name}. ` + + `Keep any leading "{...}" override block and every "\\N" break exactly as-is. ` + + `Output only the translated line, with no quotes, labels or commentary.\n\n${text}`; + const content = await chat(prompt, opts); + const cleaned = stripCodeFences(content).trim(); + return cleaned === "" ? text : cleaned; +} + +/** + * Translate a batch of items. Blank-text entries are passed through untouched + * and never cost a request. Returns translations aligned to the input order. + */ +export async function translateBatchGeneric(items: TranslateItem[], opts: GenericOptions): Promise { + if (items.length === 0) return []; + + // Indices that actually need translation. + const payloadIdx: number[] = []; + for (let i = 0; i < items.length; i++) { + if (items[i]!.text.trim() !== "") payloadIdx.push(i); + } + const result = items.map((it) => it.text); + if (payloadIdx.length === 0) return result; + + const payload = payloadIdx.map((i) => items[i]!); + + if (payload.length === 1) { + result[payloadIdx[0]!] = await translateOneGeneric(payload[0]!.text, opts); + return result; + } + + const prompt = buildGenericPrompt(payload, opts); + const content = await chat(prompt, opts); + const parsed = parseGenericResponse(content, payload.length); + + // Fill aligned ids; collect any the model dropped for per-line recovery. + const missing: number[] = []; + for (let k = 0; k < payload.length; k++) { + const t = parsed[k]; + if (t === null || t === undefined) missing.push(k); + else result[payloadIdx[k]!] = t; + } + + if (missing.length > 0) { + Logger.warn( + `[translate] Generic batch of ${payload.length} returned ${payload.length - missing.length} usable ids ` + + `(${opts.source.code}->${opts.target.code}); recovering ${missing.length} line(s) individually`, + ); + for (const k of missing) { + result[payloadIdx[k]!] = await translateOneGeneric(payload[k]!.text, opts); + } + } + + return result; +} + +/** + * Preflight for a generic model: reachability, model presence, and a real + * JSON round-trip so the Test button fails fast instead of the encode failing + * hours in. Reuses Ollama's /api/tags listing. + */ +export async function checkGenericModel( + url: string, + model: string, + source: TranslateLang, + target: TranslateLang, + signal?: AbortSignal, +): Promise<{ ok: boolean; detail: string; sample?: string }> { + const base = url.replace(/\/+$/, ""); + try { + const res = await fetch(`${base}/api/tags`, { signal }); + if (!res.ok) return { ok: false, detail: `Ollama at ${base} returned HTTP ${res.status}` }; + const data = (await res.json()) as { models?: Array<{ name?: string; model?: string }> }; + const names = (data.models ?? []).map((m) => m.name ?? m.model ?? ""); + const wanted = model.includes(":") ? model : `${model}:latest`; + const present = names.some((n) => n === model || n === wanted || n.split(":")[0] === model.split(":")[0]); + if (!present) return { ok: false, detail: `Model "${model}" not found in Ollama. Pull it with: ollama pull ${model}` }; + } catch (err) { + return { ok: false, detail: `Cannot reach Ollama at ${url}: ${(err as Error).message}` }; + } + + // Prove it actually translates and returns something usable. + try { + const sample = await translateOneGeneric("The goal of all life is death.", { + url, + model, + source, + target, + timeoutMs: 30_000, + signal, + }); + return { ok: true, detail: "", sample }; + } catch (err) { + return { ok: false, detail: `Model reachable but translation failed: ${(err as Error).message}` }; + } +} diff --git a/src/settings-code.ts b/src/settings-code.ts index ed6cbbd..8160f52 100644 --- a/src/settings-code.ts +++ b/src/settings-code.ts @@ -122,6 +122,7 @@ const BASELINE: JobSettings = { translateSignsSongs: true, translateNumCtx: 8192, translateTimeoutMs: 120000, + translateStrategy: "translategemma", vsFilters: [], }; diff --git a/src/store.ts b/src/store.ts index 55d9454..168ebdf 100644 --- a/src/store.ts +++ b/src/store.ts @@ -272,6 +272,7 @@ const SETTINGS_SANITIZERS: { [K in keyof JobSettings]?: Sanitizer } = { translateSignsSongs: bool, translateNumCtx: intIn(512, 131072), translateTimeoutMs: intIn(1000, 3600000), + translateStrategy: (v: unknown) => (v === "generic" ? "generic" : "translategemma"), }; function sanitizeSettingsInto(target: JobSettings, partial: Partial): void { diff --git a/src/subtitle-translate.ts b/src/subtitle-translate.ts index 8e8bea6..ddfe7bb 100644 --- a/src/subtitle-translate.ts +++ b/src/subtitle-translate.ts @@ -3,6 +3,7 @@ import { parseAssEvents, splitAssText, joinAssText, buildTranslatedAss, type Ass import { parseSrt, buildSrt } from "./srt-edit"; import { translateBatch, type OllamaOptions } from "./ollama"; import { resolveTranslateLang, normalizeTag, type TranslateLang } from "./translate-languages"; +import { translateBatchGeneric, type TranslateItem } from "./ollama-generic"; import { createSemaphore, type Semaphore } from "./concurrency"; /** @@ -67,6 +68,7 @@ export interface TranslateContentOptions { sem?: Semaphore; /** When false, only dialogue-classified ASS lines are translated. */ translateSignsSongs: boolean; + strategy: "translategemma" | "generic"; /** * ASS-only: predicate telling whether a style name is dialogue. Required when * `translateSignsSongs` is false; ignored for SRT. Supply via ass-classifier's @@ -87,6 +89,8 @@ interface Unit { visible: string; /** ASS leading override block to re-prepend, or "". */ lead: string; + /** Speaking character (ASS Name/Actor), passed to generic models as context. */ + name?: string; } /** @@ -116,8 +120,17 @@ async function translateUnits(units: Unit[], opts: TranslateContentOptions): Pro await Promise.all( chunks.map(async ([lo, hi]) => { const slice = units.slice(lo, hi); - const visibles = slice.map((u) => u.visible); - const translated = await sem.run(() => translateBatch(visibles, opts.ollama)); + const translated = await sem.run(() => + opts.strategy === "generic" + ? translateBatchGeneric( + slice.map((u) => ({ text: u.visible, name: u.name })), + opts.ollama, + ) + : translateBatch( + slice.map((u) => u.visible), + opts.ollama, + ), + ); for (let k = 0; k < slice.length; k++) { out.set(slice[k]!.key, translated[k]!); } @@ -140,7 +153,7 @@ async function translateAss(content: string, opts: TranslateContentOptions): Pro if (dialogueOnly && !isDialogue(ev.style)) continue; const parts = splitAssText(ev.rawText); if (!parts.translatable) continue; - units.push({ key: ev.lineNo, startMs: ev.startMs, endMs: ev.endMs, visible: parts.visible, lead: parts.lead }); + units.push({ key: ev.lineNo, startMs: ev.startMs, endMs: ev.endMs, visible: parts.visible, lead: parts.lead, name: ev.name || undefined }); } if (units.length === 0) { @@ -216,8 +229,8 @@ export interface TranslationPlan { const TEXT_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "mov_text", "text", "subviewer", "microdvd"]); /** Canonical language key for "does a full track in this language already exist". */ -function langKey(tag: string | undefined): string { - const t = resolveTranslateLang(tag); +function langKey(tag: string | undefined, resolve: (t: string | undefined) => TranslateLang | null): string { + const t = resolve(tag); if (t) return t.code; return normalizeTag(tag).split(/[-_]/)[0] || "und"; } @@ -233,16 +246,22 @@ function langKey(tag: string | undefined): string { * - The produced track mirrors the source role (honorifics source -> honorifics * output; otherwise full). */ -export function planTargetLanguages(tracks: KeptSubDescriptor[], targetTags: string[]): TranslationPlan { +export function planTargetLanguages( + tracks: KeptSubDescriptor[], + targetTags: string[], + strategy: "translategemma" | "generic" = "translategemma", +): TranslationPlan { const skipped: string[] = []; const productions: TranslationProduction[] = []; + const resolve: (t: string | undefined) => TranslateLang | null = resolveTranslateLang; + // Languages already covered by a dialogue-bearing full/honorifics track. const existing = new Set(); for (const track of tracks) { if (track.trackType === "full" || track.trackType === "honorifics") { - existing.add(langKey(track.language)); + existing.add(langKey(track.language, resolve)); } } @@ -254,24 +273,24 @@ export function planTargetLanguages(tracks: KeptSubDescriptor[], targetTags: str return { productions, skipped }; } - const sourceLang = resolveTranslateLang(source.language); + const sourceLang = resolve(source.language); if (!sourceLang) { - skipped.push(`source track language "${source.language}" is not supported by the model`); + skipped.push(`source track language "${source.language}" could not be resolved to a translatable language`); return { productions, skipped }; } - const sourceKey = langKey(source.language); + const sourceKey = langKey(source.language, resolve); const seen = new Set(); for (const rawTag of targetTags) { const tag = rawTag.trim(); if (!tag) continue; - const target = resolveTranslateLang(tag); + const target = resolve(tag); if (!target) { - skipped.push(`${tag}: not a language the model supports`); + skipped.push(`${tag}: not a language the ${strategy === "generic" ? "resolver recognizes" : "model supports"}`); continue; } diff --git a/src/translate-step.ts b/src/translate-step.ts index 5cad1ca..bea27ed 100644 --- a/src/translate-step.ts +++ b/src/translate-step.ts @@ -257,6 +257,7 @@ export async function runTranslateStep(params: RunTranslateStepParams): Promise< format, batchSize: settings.translateBatchSize, translateSignsSongs: settings.translateSignsSongs, + strategy: settings.translateStrategy === "generic" ? "generic" : "translategemma", isDialogueStyle: (style) => dialogueStyles.has(style), ollama, sem, diff --git a/src/types.ts b/src/types.ts index 733824e..ba30bbc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -242,6 +242,7 @@ export interface JobSettings { translateConcurrency?: number; /** May translation overlap the video encode? "auto" overlaps only when Ollama is NOT on a loopback address. Default "auto". */ translateDuringEncode?: "auto" | "always" | "never"; + translateStrategy: "translategemma" | "generic"; /** * Ordered list of VapourSynth filter passes to apply during the prepare * stage, before the FFmpeg -vf chain. Each entry references a preset by diff --git a/tests/ollama-generic.test.ts b/tests/ollama-generic.test.ts new file mode 100644 index 0000000..47f6c8d --- /dev/null +++ b/tests/ollama-generic.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test"; +import { + buildGenericInstruction, + buildGenericPrompt, + stripCodeFences, + extractJsonArray, + parseGenericResponse, + type TranslateItem, + type GenericOptions, +} from "../src/ollama-generic"; + +const EN = { name: "English", code: "en" }; +const SL = { name: "Slovenian", code: "sl" }; + +const baseOpts: GenericOptions = { url: "http://x:11434", model: "qwen2.5:14b", source: EN, target: SL }; + +describe("buildGenericInstruction", () => { + it("names the target language and the JSON contract", () => { + const ins = buildGenericInstruction(EN, SL); + expect(ins).toContain("into Slovenian"); + expect(ins).toContain("JSON array"); + expect(ins).toContain('"id"'); + }); + it("honors an override with placeholder substitution", () => { + const ins = buildGenericInstruction(EN, SL, "Render {source} to {target} nicely."); + expect(ins).toBe("Render English to Slovenian nicely."); + }); +}); + +describe("buildGenericPrompt", () => { + it("emits id-keyed rows and includes character names only when present", () => { + const items: TranslateItem[] = [{ text: "Hello there.", name: "Naruto" }, { text: "Good morning." }]; + const p = buildGenericPrompt(items, baseOpts); + const json = p.slice(p.indexOf("[")); + const rows = JSON.parse(json); + expect(rows).toEqual([ + { id: "0", name: "Naruto", text: "Hello there." }, + { id: "1", text: "Good morning." }, + ]); + }); +}); + +describe("stripCodeFences / extractJsonArray", () => { + it("removes json fences", () => { + expect(stripCodeFences('```json\n[{"id":"0"}]\n```')).toBe('[{"id":"0"}]'); + }); + it("pulls an array out of surrounding prose", () => { + expect(extractJsonArray('Sure! [{"id":"0","text":"a"}] done')).toBe('[{"id":"0","text":"a"}]'); + }); + it("returns null when there is no array", () => { + expect(extractJsonArray("no json here")).toBeNull(); + }); +}); + +describe("parseGenericResponse", () => { + it("maps by id regardless of order", () => { + const out = parseGenericResponse('[{"id":"1","text":"drugo"},{"id":"0","text":"prvo"}]', 2); + expect(out).toEqual(["prvo", "drugo"]); + }); + it("marks dropped ids as null", () => { + const out = parseGenericResponse('[{"id":"0","text":"prvo"}]', 2); + expect(out).toEqual(["prvo", null]); + }); + it("ignores out-of-range and malformed rows", () => { + const out = parseGenericResponse('[{"id":"9","text":"x"},{"id":"0"},{"foo":1}]', 1); + expect(out).toEqual([null]); + }); + it("accepts numeric ids and fenced output", () => { + const out = parseGenericResponse('```json\n[{"id":0,"text":"a"},{"id":1,"text":"b"}]\n```', 2); + expect(out).toEqual(["a", "b"]); + }); +}); From 4dfb0f7e2726c2d4e3bd0b3f4a5d8ee5729b3e9b Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Sat, 4 Jul 2026 10:27:38 +0200 Subject: [PATCH 04/13] Implement support for Deepseek --- public/api/client.ts | 15 +-- public/config/options.ts | 9 ++ public/features/settings-controls.ts | 50 +++++++- public/features/settings-form.ts | 75 ++++++++++-- public/index.html | 28 ++--- public/types.ts | 10 +- src/config.ts | 6 +- src/deepseek.ts | 139 +++++++++++++++++++++ src/encoder.ts | 5 +- src/index.ts | 33 ++++- src/ollama-generic.ts | 176 ++++++++++++++++++++++----- src/ollama.ts | 58 +++++---- src/settings-code.ts | 6 +- src/store.ts | 4 +- src/subtitle-translate.ts | 4 +- src/translate-provider.ts | 13 ++ src/translate-step.ts | 22 +++- src/types.ts | 9 +- tests/ollama-generic.test.ts | 24 ++++ tests/ollama.test.ts | 38 ++++++ 20 files changed, 610 insertions(+), 114 deletions(-) create mode 100644 src/deepseek.ts create mode 100644 src/translate-provider.ts diff --git a/public/api/client.ts b/public/api/client.ts index 5781582..97a49a6 100644 --- a/public/api/client.ts +++ b/public/api/client.ts @@ -394,17 +394,18 @@ export async function fetchVsDefaultEntry(presetId: string): Promise { +export async function testTranslateConnection(opts: { + provider: "ollama" | "deepseek"; + url?: string; + model: string; + apiKey?: string; + target?: string; +}): Promise<{ ok: boolean; error?: string; sample?: string; target?: string }> { try { const res = await authFetch(`${API}/api/translate/test`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url, model, target, strategy }), + body: JSON.stringify(opts), }); return await res.json(); } catch (err: any) { diff --git a/public/config/options.ts b/public/config/options.ts index aafb9db..ebf3bdf 100644 --- a/public/config/options.ts +++ b/public/config/options.ts @@ -86,6 +86,15 @@ export const PIPELINE_PRESET_HELP: Record = { custom: "Configure each pipeline stage individually below.", }; +export const TRANSLATE_PROVIDERS = ["ollama", "deepseek"] as const; +export type TranslateProviderOption = (typeof TRANSLATE_PROVIDERS)[number]; + +export const TRANSLATE_PROVIDER_LABELS: Record = { + ollama: "Ollama (local)", + deepseek: "DeepSeek (cloud)", +}; +export const DEEPSEEK_MODELS = ["deepseek-v4-flash", "deepseek-v4-pro"] as const; + export const TRANSLATE_STRATEGIES = ["translategemma", "generic"]; export const TRANSLATE_MODEL_OPTIONS = ["translategemma:4b", "translategemma:12b", "translategemma:27b"]; diff --git a/public/features/settings-controls.ts b/public/features/settings-controls.ts index 776b1df..863a97c 100644 --- a/public/features/settings-controls.ts +++ b/public/features/settings-controls.ts @@ -865,7 +865,7 @@ export function renderFontDropdown(container: HTMLElement, value: string, fonts: container.appendChild(label); } -/** Free-text control (used for ASS &HAABBGGRR colours). */ +/** Free-text control. */ export function renderTextControl(container: HTMLElement, label: string, value: string, placeholder: string, onChange: (v: string) => void): void { container.innerHTML = ""; const wrap = document.createElement("label"); @@ -882,3 +882,51 @@ export function renderTextControl(container: HTMLElement, label: string, value: wrap.appendChild(input); container.appendChild(wrap); } + +/** Free-password control. */ +export function renderPasswordControl(container: HTMLElement, label: string, value: string, placeholder: string, onChange: (v: string) => void): void { + container.innerHTML = ""; + const wrap = document.createElement("label"); + wrap.className = "toggle-label"; + const span = document.createElement("span"); + span.textContent = `${label}\u00A0`; + const input = document.createElement("input"); + input.type = "password"; + input.className = "lang-filter-input"; + input.placeholder = placeholder; + input.value = value; + input.oninput = () => onChange(input.value); + wrap.appendChild(span); + wrap.appendChild(input); + container.appendChild(wrap); +} + +export function renderSelectControl( + container: HTMLElement, + labelText: string, + options: readonly string[], + value: string, + onChange: (value: string) => void, +): void { + container.innerHTML = ""; + const label = document.createElement("label"); + label.className = "toggle-label"; + + const span = document.createElement("span"); + span.textContent = `${labelText}\u00A0`; + + const select = document.createElement("select"); + select.className = "select-input"; + for (const opt of options) { + const o = document.createElement("option"); + o.value = opt; + o.textContent = opt; + if (opt === value) o.selected = true; + select.appendChild(o); + } + select.onchange = () => onChange(select.value); + + label.appendChild(span); + label.appendChild(select); + container.appendChild(label); +} diff --git a/public/features/settings-form.ts b/public/features/settings-form.ts index 965553a..d449b01 100644 --- a/public/features/settings-form.ts +++ b/public/features/settings-form.ts @@ -5,6 +5,7 @@ import { AUDIO_ENCODE_OPTIONS, CROP_OPTIONS, DEBAND_LEVELS, + DEEPSEEK_MODELS, DEFAULT_AUTO_THRESHOLDS, DEFAULT_GRADFUN_PARAMS, DEFAULT_NLMEANS_PARAMS, @@ -18,6 +19,8 @@ import { SUBTITLE_PROCESSING_OPTIONS, SUBTITLE_SOURCE_PRIORITY_OPTIONS, TRANSLATE_MODEL_OPTIONS, + TRANSLATE_PROVIDERS, + TRANSLATE_PROVIDER_LABELS, TRANSLATE_STRATEGIES, VIDEO_ENCODE_OPTIONS, } from "../config/options"; @@ -36,8 +39,10 @@ import { renderLanguagePriorityInput, renderNoPhaseInvToggle, renderNumberControl, + renderPasswordControl, renderRadioPills, renderRemoveCommentaryAudioToggle, + renderSelectControl, renderSkipBoostingToggle, renderSubtitleConfidenceControl, renderSubtitleLangDetectControl, @@ -321,13 +326,55 @@ export function renderSettingsForm(prefix: SettingsFormPrefix, settings: JobSett settings.translateSubtitles = v; }); - renderTextControl(el("translate-url"), "Ollama URL", settings.translateOllamaUrl, "http://localhost:11434", (v) => { - settings.translateOllamaUrl = v.trim(); - }); + // Provider-specific settings, re-rendered when the provider pill changes. + const providerSettings = el("translate-provider-settings"); + const renderProviderSettings = () => { + providerSettings.innerHTML = ""; + const provider = settings.translateProvider ?? "ollama"; + const group = (cls = "toggle-group") => { + const d = document.createElement("div"); + d.className = cls; + providerSettings.appendChild(d); + return d; + }; - renderRadioPills(el("translate-model"), TRANSLATE_MODEL_OPTIONS, settings.translateModel, (v) => { - settings.translateModel = v; - }); + if (provider === "ollama") { + renderTextControl(group(), "Ollama URL", settings.translateOllamaUrl, "http://localhost:11434", (v) => { + settings.translateOllamaUrl = v.trim(); + }); + renderTextControl(group(), "Model", settings.translateModel, "translategemma:12b", (v) => { + settings.translateModel = v.trim(); + }); + const hint = document.createElement("div"); + hint.className = "lang-filter-hint"; + hint.textContent = "Any Ollama model tag. TranslateGemma models use the dedicated translation prompt; other models use the generic format automatically."; + providerSettings.appendChild(hint); + } else { + renderSelectControl(group(), "Model", DEEPSEEK_MODELS, settings.translateDeepseekModel ?? DEEPSEEK_MODELS[0], (v) => { + settings.translateDeepseekModel = v; + }); + renderPasswordControl(group(), "API key", settings.translateApiKey ?? "", "sk-...", (v) => { + settings.translateApiKey = v.trim(); + }); + } + }; + + // Provider pills with human labels (same pattern as the encoder picker). + const provEl = el("translate-provider"); + provEl.innerHTML = ""; + for (const p of TRANSLATE_PROVIDERS) { + const pill = document.createElement("div"); + pill.className = `radio-pill${p === (settings.translateProvider ?? "ollama") ? " selected" : ""}`; + pill.textContent = TRANSLATE_PROVIDER_LABELS[p]; + pill.onclick = () => { + provEl.querySelectorAll(".radio-pill").forEach((x) => x.classList.remove("selected")); + pill.classList.add("selected"); + settings.translateProvider = p; + renderProviderSettings(); + }; + provEl.appendChild(pill); + } + renderProviderSettings(); renderTranslationLanguagesInput(el("translate-targets"), settings.translateTargetLanguages, (langs) => { settings.translateTargetLanguages = langs; @@ -347,9 +394,15 @@ export function renderSettingsForm(prefix: SettingsFormPrefix, settings: JobSett testBtn.disabled = true; testResult.textContent = "Testing…"; testResult.className = "test-result"; - const firstTarget = settings.translateTargetLanguages[0]; - const strategy = settings.translateStrategy || "translategemma"; - const r = await testTranslateConnection(settings.translateOllamaUrl, settings.translateModel, firstTarget, strategy); + const provider = settings.translateProvider ?? "ollama"; + const model = provider === "ollama" ? settings.translateModel : (settings.translateDeepseekModel ?? DEEPSEEK_MODELS[0]); + const r = await testTranslateConnection({ + provider, + url: settings.translateOllamaUrl, + model, + apiKey: settings.translateApiKey, + target: settings.translateTargetLanguages[0], + }); testBtn.disabled = false; if (r.ok) { testResult.textContent = `✓ OK - sample (${r.target}): "${r.sample}"`; @@ -359,8 +412,4 @@ export function renderSettingsForm(prefix: SettingsFormPrefix, settings: JobSett testResult.classList.add("error"); } }); - - renderRadioPills(el("translate-strategy"), TRANSLATE_STRATEGIES, settings.translateStrategy, (v) => { - settings.translateStrategy = v as "translategemma" | "generic"; - }); } diff --git a/public/index.html b/public/index.html index 0156b59..44712b3 100644 --- a/public/index.html +++ b/public/index.html @@ -283,21 +283,21 @@

Default Settings

Subtitle translation
- -
+
+
+
+ +
+
-
-
-
-
-
+

@@ -539,21 +539,21 @@

Job Settings

Subtitle translation
- -
+
+
+
+ +
+
-
-
-
-
-
+

diff --git a/public/types.ts b/public/types.ts index 916c091..b889e1f 100644 --- a/public/types.ts +++ b/public/types.ts @@ -199,10 +199,16 @@ export interface JobSettings { // Subtitle translation (Ollama / TranslateGemma) /** Master switch: translate missing target languages via Ollama. */ translateSubtitles: boolean; - /** Ollama base URL (http://localhost:11434) */ + /** Translation backend. Default "ollama". */ + translateProvider: "ollama" | "deepseek"; + /** Ollama base URL, e.g. "http://localhost:11434". */ translateOllamaUrl: string; - /** Model tag (translategemma:12b) */ + /** Ollama model tag, free text, e.g. "translategemma:12b" or "qwen2.5:14b". */ translateModel: string; + /** DeepSeek model id (cloud provider). */ + translateDeepseekModel: string; + /** API key for cloud providers. Empty for Ollama. */ + translateApiKey: string; /** Languages to ensure exist (["eng","deu","fra","slv"]). */ translateTargetLanguages: string[]; /** Dialogs sent to the model per request. */ diff --git a/src/config.ts b/src/config.ts index 0c43bbc..d069fe1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -83,14 +83,16 @@ const DEFAULT_JOB_SETTINGS: JobSettings = { fontGroup: "Noto Sans", audioBitrates: DEFAULT_BITRATES, translateSubtitles: false, - translateOllamaUrl: "http://localhost:11434", + translateProvider: "ollama", translateModel: "translategemma:12b", + translateOllamaUrl: "http://localhost:11434", + translateDeepseekModel: "deepseek-v4-flash", + translateApiKey: "", translateTargetLanguages: [], translateBatchSize: 40, translateSignsSongs: true, translateNumCtx: 8192, translateTimeoutMs: 120000, - translateStrategy: "translategemma", vsFilters: [], }; diff --git a/src/deepseek.ts b/src/deepseek.ts new file mode 100644 index 0000000..b3a3dd3 --- /dev/null +++ b/src/deepseek.ts @@ -0,0 +1,139 @@ +import { Logger } from "./logger"; + +const DEEPSEEK_BASE = "https://api.deepseek.com"; +const DEFAULT_TIMEOUT_MS = 300_000; +const DEFAULT_TEMPERATURE = 0.1; +/** Batched JSON answers can be large; give the model output headroom. */ +const DEFAULT_MAX_TOKENS = 8192; +/** Retries on transient failures (429 / 5xx / network). */ +const MAX_ATTEMPTS = 3; +const RETRY_DELAYS_MS = [1_000, 3_000]; + +export interface DeepseekOptions { + apiKey: string; + /** e.g. "deepseek-v4-flash" */ + model: string; + temperature?: number; + maxTokens?: number; + timeoutMs?: number; + signal?: AbortSignal; +} + +interface ChatCompletionResponse { + choices?: Array<{ message?: { content?: string } }>; + error?: { message?: string; type?: string }; +} + +function friendlyHttpError(status: number, bodyMsg?: string): string { + if (status === 401) return "DeepSeek rejected the API key (HTTP 401). Check the key in settings."; + if (status === 402) return "DeepSeek account has insufficient balance (HTTP 402)."; + if (status === 429) return "DeepSeek rate limit hit (HTTP 429)."; + return `DeepSeek API returned HTTP ${status}${bodyMsg ? `: ${bodyMsg}` : ""}`; +} + +function isRetryable(status: number): boolean { + return status === 429 || (status >= 500 && status <= 504); +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason); + const t = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(t); + reject(signal?.reason ?? new Error("aborted")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** One chat-completions round trip with timeout, abort, and transient-error retry. */ +export async function deepseekChat(prompt: string, opts: DeepseekOptions): Promise { + let lastError: Error = new Error("DeepSeek request failed"); + + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(new Error("DeepSeek request timed out")), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); + + const onExternalAbort = () => controller.abort(opts.signal?.reason ?? new Error("aborted")); + if (opts.signal) { + if (opts.signal.aborted) controller.abort(opts.signal.reason); + else opts.signal.addEventListener("abort", onExternalAbort, { once: true }); + } + + try { + const res = await fetch(`${DEEPSEEK_BASE}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${opts.apiKey}`, + }, + signal: controller.signal, + body: JSON.stringify({ + model: opts.model, + stream: false, + messages: [{ role: "user", content: prompt }], + temperature: opts.temperature ?? DEFAULT_TEMPERATURE, + max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS, + }), + }); + + if (!res.ok) { + const bodyMsg = await res + .json() + .then((j: ChatCompletionResponse) => j?.error?.message) + .catch(() => undefined); + const err = new Error(friendlyHttpError(res.status, bodyMsg)); + if (isRetryable(res.status) && attempt < MAX_ATTEMPTS - 1) { + lastError = err; + Logger.warn(`[translate] ${err.message} — retrying (${attempt + 1}/${MAX_ATTEMPTS - 1})`); + await sleep(RETRY_DELAYS_MS[attempt] ?? 3_000, opts.signal); + continue; + } + throw err; + } + + const data = (await res.json()) as ChatCompletionResponse; + if (data.error) throw new Error(`DeepSeek error: ${data.error.message ?? data.error.type ?? "unknown"}`); + return data.choices?.[0]?.message?.content ?? ""; + } catch (err) { + // Abort (job cancel / timeout) is never retried. + if (opts.signal?.aborted || controller.signal.aborted) throw err; + lastError = err as Error; + if (attempt < MAX_ATTEMPTS - 1) { + Logger.warn(`[translate] DeepSeek request failed (${lastError.message}) — retrying`); + await sleep(RETRY_DELAYS_MS[attempt] ?? 3_000, opts.signal); + continue; + } + throw lastError; + } finally { + clearTimeout(timeout); + opts.signal?.removeEventListener("abort", onExternalAbort); + } + } + throw lastError; +} + +/** Probe DeepSeek: key validity and model availability, via GET /models. */ +export async function checkDeepseek(apiKey: string, model: string, signal?: AbortSignal): Promise<{ ok: boolean; detail: string }> { + if (!apiKey.trim()) return { ok: false, detail: "no DeepSeek API key is configured" }; + try { + const res = await fetch(`${DEEPSEEK_BASE}/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal, + }); + if (res.status === 401) return { ok: false, detail: "DeepSeek rejected the API key (HTTP 401)" }; + if (!res.ok) return { ok: false, detail: `DeepSeek /models returned HTTP ${res.status}` }; + const data = (await res.json()) as { data?: Array<{ id?: string }> }; + const ids = (data.data ?? []).map((m) => m.id ?? ""); + if (ids.length > 0 && !ids.includes(model)) { + return { ok: false, detail: `Model "${model}" not available on this DeepSeek account (available: ${ids.join(", ")})` }; + } + return { ok: true, detail: "" }; + } catch (err) { + return { ok: false, detail: `Cannot reach DeepSeek API: ${(err as Error).message}` }; + } +} diff --git a/src/encoder.ts b/src/encoder.ts index 6d44110..812dcea 100644 --- a/src/encoder.ts +++ b/src/encoder.ts @@ -618,9 +618,10 @@ export async function encodeJob( karaoke: job.settings.detectKaraokeAudio, }; - const ollamaIsLoopback = /(?:\/\/)(?:localhost|127\.0\.0\.1|\[?::1\]?|0\.0\.0\.0)(?::|\/|$)/i.test(job.settings.translateOllamaUrl); + const translateProvider = job.settings.translateProvider ?? "ollama"; + const translateIsLocal = translateProvider === "ollama" && /(?:localhost|127\.0\.0\.1|\[?::1\]?)(?::|\/|$)/i.test(job.settings.translateOllamaUrl); const overlapPolicy = job.settings.translateDuringEncode ?? "auto"; - const overlapTranslate = overlapPolicy === "always" || (overlapPolicy === "auto" && !ollamaIsLoopback); + const overlapTranslate = overlapPolicy === "always" || (overlapPolicy === "auto" && !translateIsLocal); const stageAbort = new AbortController(); const stageSignal = stageAbort.signal; diff --git a/src/index.ts b/src/index.ts index 8d46ef8..726e3db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,7 +45,9 @@ import type { GroupStyleConfig } from "./subtitle-style"; import { isInsideRoots, listSystemFonts } from "./system-fonts"; import { checkOllama, translateOne } from "./ollama"; import { resolveTranslateLang } from "./translate-languages"; -import { checkGenericModel } from "./ollama-generic"; +import { checkGenericChat, checkGenericModel } from "./ollama-generic"; +import { resolveTranslateStrategy, type TranslateProvider } from "./translate-provider"; +import { checkDeepseek } from "./deepseek"; export const config = await loadConfig(); @@ -389,17 +391,36 @@ app.post("/api/config/import-code", async (c) => { }); app.post("/api/translate/test", async (c) => { - const body = (await c.req.json().catch(() => ({}))) as { url?: string; strategy?: string; model?: string; target?: string }; - const url = (body.url || "").trim(); + const body = (await c.req.json().catch(() => ({}))) as { + provider?: string; + url?: string; + model?: string; + apiKey?: string; + target?: string; + }; + const provider: TranslateProvider = body.provider === "deepseek" ? "deepseek" : "ollama"; const model = (body.model || "").trim(); + const source = { name: "English", code: "en" }; + const target = resolveTranslateLang(body.target || "slv") ?? { name: "Slovenian", code: "sl" }; + + if (provider === "deepseek") { + const apiKey = (body.apiKey || "").trim(); + if (!apiKey || !model) return c.json({ ok: false, error: "Missing DeepSeek API key or model" }, 400); + + const health = await checkDeepseek(apiKey, model); + if (!health.ok) return c.json({ ok: false, error: health.detail }); + + const r = await checkGenericChat({ provider: "deepseek", url: "", apiKey, model, source, target }); + return c.json(r.ok ? { ok: true, sample: r.sample, model, target: target.name } : { ok: false, error: r.detail }); + } + + const url = (body.url || "").trim(); if (!url || !model) return c.json({ ok: false, error: "Missing Ollama URL or model" }, 400); const health = await checkOllama(url, model); if (!health.ok) return c.json({ ok: false, error: health.detail }); - const strategy = body.strategy === "generic" ? "generic" : "translategemma"; - const source = { name: "English", code: "en" }; - const target = resolveTranslateLang(body.target || "slv") ?? { name: "Slovenian", code: "sl" }; + const strategy = resolveTranslateStrategy(provider, model); if (strategy === "generic") { const r = await checkGenericModel(url, model, source, target); diff --git a/src/ollama-generic.ts b/src/ollama-generic.ts index 84d2364..e308b9d 100644 --- a/src/ollama-generic.ts +++ b/src/ollama-generic.ts @@ -1,3 +1,4 @@ +import { deepseekChat } from "./deepseek"; import { Logger } from "./logger"; import type { TranslateLang } from "./translate-languages"; @@ -29,9 +30,13 @@ export interface TranslateItem { } export interface GenericOptions { - /** Base URL, e.g. "http://localhost:11434". */ + /** Transport. Default "ollama". */ + provider?: "ollama" | "deepseek"; + /** Ollama base URL, e.g. "http://localhost:11434". Ignored for cloud providers. */ url: string; - /** Model tag, e.g. "qwen2.5:14b", "llama3.1:8b". */ + /** API key for cloud providers. Ignored for Ollama. */ + apiKey?: string; + /** Model tag ("qwen2.5:14b") or cloud model id ("deepseek-v4-flash"). */ model: string; source: TranslateLang; target: TranslateLang; @@ -55,6 +60,11 @@ const DEFAULT_TIMEOUT_MS = 180_000; const DEFAULT_NUM_CTX = 8192; const DEFAULT_TEMPERATURE = 0.1; +/** Below this many missing entries, per-line recovery is cheaper than another batch. */ +const MIN_BATCH_RECOVERY = 3; +/** Hard cap on recovery recursion depth (each level strictly shrinks the batch). */ +const MAX_RECOVERY_DEPTH = 5; + /** Payload row sent to the model. */ interface PayloadRow { id: string; @@ -62,6 +72,97 @@ interface PayloadRow { text: string; } +/** One prompt round-trip: per-entry translations, null where the id was dropped/unusable. */ +async function attemptBatch(payload: TranslateItem[], opts: GenericOptions): Promise<(string | null)[]> { + const prompt = buildGenericPrompt(payload, opts); + const content = await chat(prompt, opts); + return parseGenericResponse(content, payload.length); +} + +/** + * Repair invalid JSON escape sequences the model may emit when reproducing + * protected subtitle formatting (typically the ASS line break written raw as + * `\N` instead of `\\N`, which makes the whole array unparseable). + * + * The regex matches, in order: a complete valid escape (`\"`, `\\`, `\/`, + * `\b`, `\f`, `\n`, `\r`, `\t`, or `\uXXXX`) — kept verbatim and consumed + * whole so its trailing character is never re-examined — or otherwise a lone + * backslash, which is never valid JSON and is doubled. Lossless on + * well-formed input. + */ +export function repairJsonEscapes(s: string): string { + return s.replace(/\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4})|\\/g, (m) => (m.length > 1 ? m : "\\\\")); +} + +/** + * Translate a payload of non-blank items, recovering failures by re-batching + * progressively smaller groups instead of dropping to per-line requests. + * Per-line recovery costs roughly a full prompt per dialog, so a mangled + * 40-line batch would cost ~40 extra requests; re-batching the missing subset + * (or halving on total failure) recovers the same lines in one or two. + */ +async function translateResilient(payload: TranslateItem[], opts: GenericOptions, depth: number): Promise { + if (payload.length === 0) return []; + if (payload.length === 1) return [await translateOneGeneric(payload[0]!.text, opts)]; + + const halve = async (): Promise => { + const mid = Math.ceil(payload.length / 2); + const left = await translateResilient(payload.slice(0, mid), opts, depth + 1); + const right = await translateResilient(payload.slice(mid), opts, depth + 1); + return [...left, ...right]; + }; + + let parsed: (string | null)[]; + try { + parsed = await attemptBatch(payload, opts); + } catch (err) { + // Transport failure that survived the client's own retries. Smaller + // prompts are cheaper to retry and likelier to fit/succeed. + if (depth >= MAX_RECOVERY_DEPTH) throw err; + Logger.warn( + `[translate] Generic batch of ${payload.length} failed (${(err as Error).message}) ` + `(${opts.source.code}->${opts.target.code}); splitting in half`, + ); + return halve(); + } + + const result = new Array(payload.length); + const missingIdx: number[] = []; + for (let i = 0; i < payload.length; i++) { + const t = parsed[i]; + if (t === null || t === undefined) missingIdx.push(i); + else result[i] = t; + } + if (missingIdx.length === 0) return result; + + // Last resort, or a tail so small the batch wrapper no longer pays for itself. + if (depth >= MAX_RECOVERY_DEPTH || missingIdx.length < MIN_BATCH_RECOVERY) { + Logger.warn(`[translate] Recovering ${missingIdx.length} straggler line(s) individually (${opts.source.code}->${opts.target.code})`); + for (const i of missingIdx) result[i] = await translateOneGeneric(payload[i]!.text, opts); + return result; + } + + // Nothing usable at all: the response was garbage, so change the prompt + // size rather than resending the same thing. + if (missingIdx.length === payload.length) { + Logger.warn(`[translate] Generic batch of ${payload.length} returned no usable ids ` + `(${opts.source.code}->${opts.target.code}); splitting in half`); + return halve(); + } + + // Partial drop: the format works, the model just lost some ids. + // Re-batch only the missing entries in one smaller request. + Logger.warn( + `[translate] Generic batch of ${payload.length} returned ${payload.length - missingIdx.length} usable ids ` + + `(${opts.source.code}->${opts.target.code}); re-batching ${missingIdx.length} missing entrie(s)`, + ); + const sub = await translateResilient( + missingIdx.map((i) => payload[i]!), + opts, + depth + 1, + ); + for (let k = 0; k < missingIdx.length; k++) result[missingIdx[k]!] = sub[k]!; + return result; +} + /** * Build the instruction block. Based on a natural-dialogue subtitle prompt, * adapted for id-keyed JSON I/O and the character-name context. @@ -87,6 +188,7 @@ export function buildGenericInstruction(source: TranslateLang, target: Translate `- Preserve protected formatting verbatim: any leading "{...}" override block and every "\\N" line break must appear unchanged in the output text.`, `- Do not merge, split, reorder, add or remove entries.`, `- Return ONLY a JSON array of objects {"id": "...", "text": "..."}, one per input entry, with no commentary, no explanations and no markdown fences.`, + `- In the JSON output, remember "\\N" must be escaped as "\\\\N" inside string values.`, ].join("\n"); } @@ -123,6 +225,11 @@ export function extractJsonArray(s: string): string | null { /** * Parse a model response into a map id->text. Returns an array aligned to * `count` (index === numeric id); entries the model dropped are `null`. + * + * Tolerates: markdown fences, prose around the array, reordered rows, + * numeric ids, and raw `\N` escapes inside strings (repaired on a second + * parse attempt so a formatting slip doesn't discard an otherwise-good + * batch and trigger expensive recovery). */ export function parseGenericResponse(content: string, count: number): (string | null)[] { const out: (string | null)[] = new Array(count).fill(null); @@ -133,7 +240,15 @@ export function parseGenericResponse(content: string, count: number): (string | try { parsed = JSON.parse(json); } catch { - return out; + // Second chance: the array shape is usually fine and only the string + // escaping is broken (typically a raw `\N`). Repair and retry before + // declaring the batch unusable. + try { + parsed = JSON.parse(repairJsonEscapes(json)); + Logger.warn("[translate] Model emitted invalid JSON escapes (raw \\N?); repaired and parsed successfully"); + } catch { + return out; + } } if (!Array.isArray(parsed)) return out; @@ -157,6 +272,16 @@ interface OllamaChatResponse { /** One /api/chat round-trip. Throws on HTTP, network, timeout or abort. */ async function chat(prompt: string, opts: GenericOptions): Promise { + if (opts.provider === "deepseek") { + return deepseekChat(prompt, { + apiKey: opts.apiKey ?? "", + model: opts.model, + temperature: opts.temperature, + timeoutMs: opts.timeoutMs, + signal: opts.signal, + }); + } + const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(new Error("Ollama request timed out")), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); const onExternalAbort = () => controller.abort(opts.signal?.reason ?? new Error("aborted")); @@ -213,7 +338,6 @@ export async function translateOneGeneric(text: string, opts: GenericOptions): P export async function translateBatchGeneric(items: TranslateItem[], opts: GenericOptions): Promise { if (items.length === 0) return []; - // Indices that actually need translation. const payloadIdx: number[] = []; for (let i = 0; i < items.length; i++) { if (items[i]!.text.trim() !== "") payloadIdx.push(i); @@ -222,34 +346,10 @@ export async function translateBatchGeneric(items: TranslateItem[], opts: Generi if (payloadIdx.length === 0) return result; const payload = payloadIdx.map((i) => items[i]!); - - if (payload.length === 1) { - result[payloadIdx[0]!] = await translateOneGeneric(payload[0]!.text, opts); - return result; - } - - const prompt = buildGenericPrompt(payload, opts); - const content = await chat(prompt, opts); - const parsed = parseGenericResponse(content, payload.length); - - // Fill aligned ids; collect any the model dropped for per-line recovery. - const missing: number[] = []; + const translated = await translateResilient(payload, opts, 0); for (let k = 0; k < payload.length; k++) { - const t = parsed[k]; - if (t === null || t === undefined) missing.push(k); - else result[payloadIdx[k]!] = t; + result[payloadIdx[k]!] = translated[k]!; } - - if (missing.length > 0) { - Logger.warn( - `[translate] Generic batch of ${payload.length} returned ${payload.length - missing.length} usable ids ` + - `(${opts.source.code}->${opts.target.code}); recovering ${missing.length} line(s) individually`, - ); - for (const k of missing) { - result[payloadIdx[k]!] = await translateOneGeneric(payload[k]!.text, opts); - } - } - return result; } @@ -293,3 +393,19 @@ export async function checkGenericModel( return { ok: false, detail: `Model reachable but translation failed: ${(err as Error).message}` }; } } + +/** + * Real JSON round-trip against any provider: translates two sample lines via + * the normal generic batch path so the Test button exercises exactly what a + * job will run. + */ +export async function checkGenericChat(opts: GenericOptions): Promise<{ ok: boolean; detail: string; sample?: string }> { + try { + const out = await translateBatchGeneric([{ text: "The goal of all life is death." }, { text: "See you tomorrow." }], opts); + const sample = out[0]?.trim(); + if (!sample) return { ok: false, detail: "Model returned an empty translation" }; + return { ok: true, detail: "", sample }; + } catch (err) { + return { ok: false, detail: (err as Error).message }; + } +} diff --git a/src/ollama.ts b/src/ollama.ts index f938eb5..a515adf 100644 --- a/src/ollama.ts +++ b/src/ollama.ts @@ -89,6 +89,14 @@ async function chat(prompt: string, opts: OllamaOptions): Promise { } } +/** One block attempt: returns aligned translations, or null on line-count mismatch. */ +async function attemptBlock(payload: string[], opts: OllamaOptions): Promise { + const block = payload.join("\n"); + const content = await chat(buildTranslatePrompt(opts.source, opts.target, block), opts); + const outLines = splitResponseLines(content); + return outLines.length === payload.length ? outLines : null; +} + /** Translate a single line/string. */ export async function translateOne(text: string, opts: OllamaOptions): Promise { if (text.trim() === "") return text; @@ -103,10 +111,31 @@ function splitResponseLines(content: string): string[] { return lines; } +/** + * Translate non-blank lines, bisecting on misalignment. TranslateGemma + * occasionally merges/splits lines within a block; rather than redoing the + * whole chunk line-by-line (n requests, ~full prompt each), split the block + * in half and recurse — clean halves are kept, and the offending line is + * isolated in log(n) retries. + */ +async function translateBlockResilient(payload: string[], opts: OllamaOptions): Promise { + if (payload.length === 0) return []; + if (payload.length === 1) return [await translateOne(payload[0]!, opts)]; + + const aligned = await attemptBlock(payload, opts); + if (aligned) return aligned; + + Logger.warn(`[translate] Block of ${payload.length} lines misaligned (${opts.source.code}->${opts.target.code}); bisecting`); + const mid = Math.ceil(payload.length / 2); + const left = await translateBlockResilient(payload.slice(0, mid), opts); + const right = await translateBlockResilient(payload.slice(mid), opts); + return [...left, ...right]; +} + /** * Translate a batch of lines in one request, returning exactly `lines.length` - * results. If the response doesn't line up 1:1, fall back to translating each - * line on its own so cue alignment is never lost. + * results. If the response doesn't line up 1:1, the block is bisected until + * aligned halves are found, so cue alignment is never lost. * * Empty input lines are passed through untouched and don't cost a request. */ @@ -123,29 +152,8 @@ export async function translateBatch(lines: string[], opts: OllamaOptions): Prom const payload = payloadIdx.map((i) => lines[i]!); const result = [...lines]; - if (payload.length === 1) { - result[payloadIdx[0]!] = await translateOne(payload[0]!, opts); - return result; - } - - const block = payload.join("\n"); - const prompt = buildTranslatePrompt(opts.source, opts.target, block); - const content = await chat(prompt, opts); - const outLines = splitResponseLines(content); - - if (outLines.length === payload.length) { - payloadIdx.forEach((origIdx, k) => (result[origIdx] = outLines[k]!)); - return result; - } - - // Misaligned - the model merged/split lines. Redo this batch line-by-line. - Logger.warn( - `[translate] Batch of ${payload.length} lines returned ${outLines.length} lines (${opts.source.code}->${opts.target.code}); ` + - `falling back to per-line translation for this chunk`, - ); - for (let k = 0; k < payload.length; k++) { - result[payloadIdx[k]!] = await translateOne(payload[k]!, opts); - } + const translated = await translateBlockResilient(payload, opts); + payloadIdx.forEach((origIdx, k) => (result[origIdx] = translated[k]!)); return result; } diff --git a/src/settings-code.ts b/src/settings-code.ts index 8160f52..97cdaca 100644 --- a/src/settings-code.ts +++ b/src/settings-code.ts @@ -115,14 +115,16 @@ const BASELINE: JobSettings = { "7.1.4": 512, }, translateSubtitles: false, - translateOllamaUrl: "http://localhost:11434", + translateProvider: "ollama", translateModel: "translategemma:12b", + translateOllamaUrl: "http://localhost:11434", + translateDeepseekModel: "deepseek-v4-flash", + translateApiKey: "", translateTargetLanguages: [], translateBatchSize: 40, translateSignsSongs: true, translateNumCtx: 8192, translateTimeoutMs: 120000, - translateStrategy: "translategemma", vsFilters: [], }; diff --git a/src/store.ts b/src/store.ts index 168ebdf..66b5e2b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -265,14 +265,16 @@ const SETTINGS_SANITIZERS: { [K in keyof JobSettings]?: Sanitizer } = { fontGroup: str(256), translateSubtitles: bool, + translateProvider: enumOf(["ollama", "deepseek"]), translateOllamaUrl: str(512), translateModel: str(128), + translateDeepseekModel: enumOf(["deepseek-v4-flash", "deepseek-v4-pro"]), + translateApiKey: str(512), translateTargetLanguages: strList, translateBatchSize: intIn(1, 1000), translateSignsSongs: bool, translateNumCtx: intIn(512, 131072), translateTimeoutMs: intIn(1000, 3600000), - translateStrategy: (v: unknown) => (v === "generic" ? "generic" : "translategemma"), }; function sanitizeSettingsInto(target: JobSettings, partial: Partial): void { diff --git a/src/subtitle-translate.ts b/src/subtitle-translate.ts index ddfe7bb..2396ee1 100644 --- a/src/subtitle-translate.ts +++ b/src/subtitle-translate.ts @@ -3,7 +3,7 @@ import { parseAssEvents, splitAssText, joinAssText, buildTranslatedAss, type Ass import { parseSrt, buildSrt } from "./srt-edit"; import { translateBatch, type OllamaOptions } from "./ollama"; import { resolveTranslateLang, normalizeTag, type TranslateLang } from "./translate-languages"; -import { translateBatchGeneric, type TranslateItem } from "./ollama-generic"; +import { translateBatchGeneric, type GenericOptions, type TranslateItem } from "./ollama-generic"; import { createSemaphore, type Semaphore } from "./concurrency"; /** @@ -75,7 +75,7 @@ export interface TranslateContentOptions { * `dialogueStyleNames` in production. */ isDialogueStyle?: (style: string) => boolean; - ollama: OllamaOptions; + ollama: GenericOptions; /** Reports cumulative translated-line progress. */ onProgress?: (done: number, total: number) => void; } diff --git a/src/translate-provider.ts b/src/translate-provider.ts new file mode 100644 index 0000000..7121353 --- /dev/null +++ b/src/translate-provider.ts @@ -0,0 +1,13 @@ +/** Where translation requests are sent. */ +export type TranslateProvider = "ollama" | "deepseek"; + +/** + * Derive the prompt/parse strategy from provider + model name. + * TranslateGemma is a translation-only model with its own prompt format; + * everything else (including all cloud models) uses the generic + * id-keyed JSON strategy. + */ +export function resolveTranslateStrategy(provider: TranslateProvider, model: string): "translategemma" | "generic" { + if (provider !== "ollama") return "generic"; + return model.trim().toLowerCase().includes("translategemma") ? "translategemma" : "generic"; +} diff --git a/src/translate-step.ts b/src/translate-step.ts index bea27ed..345a047 100644 --- a/src/translate-step.ts +++ b/src/translate-step.ts @@ -9,6 +9,9 @@ import { styleSrtAss, restyleAssDialogueFont } from "./ass-style"; import { checkOllama, type OllamaOptions } from "./ollama"; import { planTargetLanguages, translateSubtitleContent, type KeptSubDescriptor } from "./subtitle-translate"; import { createSemaphore } from "./concurrency"; +import { resolveTranslateStrategy } from "./translate-provider"; +import { checkDeepseek } from "./deepseek"; +import type { GenericOptions } from "./ollama-generic"; /** * A finished, on-disk translated subtitle ready to hand to mkvmerge. Shaped to @@ -180,8 +183,12 @@ export async function runTranslateStep(params: RunTranslateStepParams): Promise< const targets = settings.translateTargetLanguages ?? []; if (!settings.translateSubtitles || targets.length === 0) return []; + const provider = settings.translateProvider ?? "ollama"; + const model = provider === "deepseek" ? settings.translateDeepseekModel || "deepseek-v4-flash" : settings.translateModel; + const strategy = resolveTranslateStrategy(provider, model); + const descriptors = buildKeptDescriptors(subtitleStreams); - const plan = planTargetLanguages(descriptors, targets); + const plan = planTargetLanguages(descriptors, targets, strategy); for (const note of plan.skipped) Logger.info(`[translate] Skipped ${note}`); if (plan.productions.length === 0) { Logger.info("[translate] Nothing to translate (all target languages already present or unsupported)"); @@ -189,7 +196,10 @@ export async function runTranslateStep(params: RunTranslateStepParams): Promise< } // Preflight: fail fast with a clear message rather than shipping untranslated. - const health = await checkOllama(settings.translateOllamaUrl, settings.translateModel, signal); + const health = + provider === "deepseek" + ? await checkDeepseek(settings.translateApiKey ?? "", model, signal) + : await checkOllama(settings.translateOllamaUrl, model, signal); if (!health.ok) { throw new Error(`Subtitle translation is enabled but ${health.detail}`); } @@ -243,9 +253,11 @@ export async function runTranslateStep(params: RunTranslateStepParams): Promise< await Promise.all( plan.productions.map(async (prod, li) => { - const ollama: OllamaOptions = { + const ollama: GenericOptions = { + provider, url: settings.translateOllamaUrl, - model: settings.translateModel, + apiKey: settings.translateApiKey, + model, source: prod.source, target: prod.target, numCtx: settings.translateNumCtx, @@ -257,7 +269,7 @@ export async function runTranslateStep(params: RunTranslateStepParams): Promise< format, batchSize: settings.translateBatchSize, translateSignsSongs: settings.translateSignsSongs, - strategy: settings.translateStrategy === "generic" ? "generic" : "translategemma", + strategy, isDialogueStyle: (style) => dialogueStyles.has(style), ollama, sem, diff --git a/src/types.ts b/src/types.ts index ba30bbc..d6d429c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -224,10 +224,16 @@ export interface JobSettings { // Subtitle translation (Ollama / TranslateGemma) /** Master switch: translate missing target languages via Ollama. */ translateSubtitles: boolean; + /** Translation backend. Default "ollama". */ + translateProvider: "ollama" | "deepseek"; /** Ollama base URL, e.g. "http://localhost:11434". */ translateOllamaUrl: string; - /** Model tag, e.g. "translategemma:12b". */ + /** Ollama model tag, free text, e.g. "translategemma:12b" or "qwen2.5:14b". */ translateModel: string; + /** DeepSeek model id (cloud provider). */ + translateDeepseekModel: string; + /** API key for cloud providers. Empty for Ollama. */ + translateApiKey: string; /** Languages to ensure exist (ISO-639-2 or -1, e.g. ["eng","deu","fra","slv"]). */ translateTargetLanguages: string[]; /** Dialogs sent to the model per request. Lower = better context, slower. */ @@ -242,7 +248,6 @@ export interface JobSettings { translateConcurrency?: number; /** May translation overlap the video encode? "auto" overlaps only when Ollama is NOT on a loopback address. Default "auto". */ translateDuringEncode?: "auto" | "always" | "never"; - translateStrategy: "translategemma" | "generic"; /** * Ordered list of VapourSynth filter passes to apply during the prepare * stage, before the FFmpeg -vf chain. Each entry references a preset by diff --git a/tests/ollama-generic.test.ts b/tests/ollama-generic.test.ts index 47f6c8d..d9a279b 100644 --- a/tests/ollama-generic.test.ts +++ b/tests/ollama-generic.test.ts @@ -7,6 +7,7 @@ import { parseGenericResponse, type TranslateItem, type GenericOptions, + repairJsonEscapes, } from "../src/ollama-generic"; const EN = { name: "English", code: "en" }; @@ -70,3 +71,26 @@ describe("parseGenericResponse", () => { expect(out).toEqual(["a", "b"]); }); }); + +describe("repairJsonEscapes / lenient parsing", () => { + // Simulates DeepSeek writing the ASS break raw: `\N` instead of `\\N`. + const badJson = '[{"id":"0","text":"got a job \\Nat a firm"},{"id":"1","text":"ok"}]'.replace("\\N", "\\\u004E"); + + it("raw \\N breaks strict JSON.parse", () => { + // Build the invalid string explicitly: backslash + N inside a JSON string. + const invalid = '[{"id":"0","text":"a \\' + 'Nb"}]'; + expect(() => JSON.parse(invalid)).toThrow(); + expect(JSON.parse(repairJsonEscapes(invalid))[0].text).toBe("a \\Nb"); + }); + + it("leaves valid escapes untouched", () => { + const valid = '[{"id":"0","text":"quote \\" newline \\n break \\\\N"}]'; + expect(repairJsonEscapes(valid)).toBe(valid); + }); + + it("parseGenericResponse recovers a batch with raw \\N escapes", () => { + const invalid = '[{"id":"0","text":"prva \\' + 'Ndruga"},{"id":"1","text":"ok"}]'; + const out = parseGenericResponse(invalid, 2); + expect(out).toEqual(["prva \\Ndruga", "ok"]); + }); +}); diff --git a/tests/ollama.test.ts b/tests/ollama.test.ts index 3516bb1..b4e0acd 100644 --- a/tests/ollama.test.ts +++ b/tests/ollama.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, afterEach } from "bun:test"; import { translateBatch, translateOne, buildTranslatePrompt } from "../src/ollama"; import { planTargetLanguages, type KeptSubDescriptor } from "../src/subtitle-translate"; import type { OllamaOptions } from "../src/ollama"; +import { repairJsonEscapes } from "../src/ollama-generic"; const EN = { name: "English", code: "en" }; const SL = { name: "Slovenian", code: "sl" }; @@ -81,6 +82,43 @@ describe("ollama.translateBatch", () => { globalThis.fetch = (async () => new Response("boom", { status: 500 })) as unknown as typeof fetch; await expect(translateOne("hi", opts())).rejects.toThrow(/Ollama HTTP 500/); }); + + it("bisects misaligned batches instead of going per-line", async () => { + let calls = 0; + globalThis.fetch = (async (_url: string, init: any) => { + calls++; + const prompt = JSON.parse(init.body).messages[0].content as string; + const block = prompt.split("\n\n\n")[1] ?? ""; + const lines = block.split("\n"); + // Blocks containing the poison line misalign; everything else is clean. + const content = lines.includes("POISON") && lines.length > 1 ? "merged output" : lines.map((l) => `X:${l}`).join("\n"); + return new Response(JSON.stringify({ message: { content } }), { status: 200 }); + }) as unknown as typeof fetch; + + const input = ["a", "b", "c", "POISON", "d", "e", "f", "g"]; + const out = await translateBatch(input, opts()); + expect(out).toEqual(input.map((l) => `X:${l}`)); + expect(calls).toBe(7); + }); + + it("bisection cost stays logarithmic at real batch sizes", async () => { + let calls = 0; + globalThis.fetch = (async (_url: string, init: any) => { + calls++; + const prompt = JSON.parse(init.body).messages[0].content as string; + const block = prompt.split("Slovenian:\n\n\n")[1] ?? ""; + const lines = block.split("\n"); + const content = lines.includes("POISON") && lines.length > 1 ? "merged output" : lines.map((l) => `X:${l}`).join("\n"); + return new Response(JSON.stringify({ message: { content } }), { status: 200 }); + }) as unknown as typeof fetch; + + // 40 lines, one poison line in the middle. + const input = Array.from({ length: 40 }, (_, i) => (i === 17 ? "POISON" : `line ${i}`)); + const out = await translateBatch(input, opts()); + expect(out).toEqual(input.map((l) => `X:${l}`)); + + expect(calls).toBeLessThanOrEqual(13); + }); }); describe("planTargetLanguages", () => { From 7ceab2633b0bf65195f968b0bdefd0dfbab82a6c Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Sat, 4 Jul 2026 11:52:22 +0200 Subject: [PATCH 05/13] Improve more translation options thru UI --- public/features/settings-form.ts | 19 +++++++++++++++++++ public/index.html | 4 ++++ public/types.ts | 2 ++ src/config.ts | 1 + src/ollama-generic.ts | 3 +++ src/subtitle-translate.ts | 30 ++++++++++++++++++++++++++++-- src/translate-step.ts | 5 +++-- src/types.ts | 2 ++ 8 files changed, 62 insertions(+), 4 deletions(-) diff --git a/public/features/settings-form.ts b/public/features/settings-form.ts index d449b01..121659f 100644 --- a/public/features/settings-form.ts +++ b/public/features/settings-form.ts @@ -349,6 +349,10 @@ export function renderSettingsForm(prefix: SettingsFormPrefix, settings: JobSett hint.className = "lang-filter-hint"; hint.textContent = "Any Ollama model tag. TranslateGemma models use the dedicated translation prompt; other models use the generic format automatically."; providerSettings.appendChild(hint); + + renderNumberControl(group(), "Context window (num_ctx)", settings.translateNumCtx ?? 8192, { min: 2048, max: 131072, step: 1024 }, (v) => { + settings.translateNumCtx = v; + }); } else { renderSelectControl(group(), "Model", DEEPSEEK_MODELS, settings.translateDeepseekModel ?? DEEPSEEK_MODELS[0], (v) => { settings.translateDeepseekModel = v; @@ -356,6 +360,9 @@ export function renderSettingsForm(prefix: SettingsFormPrefix, settings: JobSett renderPasswordControl(group(), "API key", settings.translateApiKey ?? "", "sk-...", (v) => { settings.translateApiKey = v.trim(); }); + renderNumberControl(group(), "Max output tokens", settings.translateMaxTokens ?? 8192, { min: 1024, max: 32768, step: 512 }, (v) => { + settings.translateMaxTokens = v; + }); } }; @@ -384,6 +391,18 @@ export function renderSettingsForm(prefix: SettingsFormPrefix, settings: JobSett settings.translateBatchSize = v; }); + renderNumberControl( + el("translate-timeout"), + "Request timeout (s)", + Math.round((settings.translateTimeoutMs ?? 180_000) / 1000), + { min: 10, max: 3600, step: 10 }, + (v) => (settings.translateTimeoutMs = v * 1000), + ); + + renderNumberControl(el("translate-concurrency"), "Parallel requests", settings.translateConcurrency ?? 1, { min: 1, max: 16, step: 1 }, (v) => { + settings.translateConcurrency = v; + }); + renderLabeledToggle(el("translate-signs"), settings.translateSignsSongs, "Also translate signs & songs", (v) => { settings.translateSignsSongs = v; }); diff --git a/public/index.html b/public/index.html index 44712b3..5410ef9 100644 --- a/public/index.html +++ b/public/index.html @@ -293,6 +293,8 @@

Default Settings

+
+
@@ -549,6 +551,8 @@

Job Settings

+
+
diff --git a/public/types.ts b/public/types.ts index b889e1f..0af6daa 100644 --- a/public/types.ts +++ b/public/types.ts @@ -219,6 +219,8 @@ export interface JobSettings { translateNumCtx: number; /** Per-request timeout, ms. */ translateTimeoutMs: number; + /** Max output tokens per request (cloud providers). Default 8192. */ + translateMaxTokens?: number; /** Max concurrent in-flight Ollama requests (all languages + chunks). Keep <= server OLLAMA_NUM_PARALLEL. Default 1. */ translateConcurrency?: number; /** May translation overlap the video encode? "auto" overlaps only when Ollama is NOT on a loopback address. Default "auto". */ diff --git a/src/config.ts b/src/config.ts index d069fe1..897989b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -93,6 +93,7 @@ const DEFAULT_JOB_SETTINGS: JobSettings = { translateSignsSongs: true, translateNumCtx: 8192, translateTimeoutMs: 120000, + translateMaxTokens: 8192, vsFilters: [], }; diff --git a/src/ollama-generic.ts b/src/ollama-generic.ts index e308b9d..0b0d349 100644 --- a/src/ollama-generic.ts +++ b/src/ollama-generic.ts @@ -46,6 +46,8 @@ export interface GenericOptions { temperature?: number; /** Per-request timeout in ms. */ timeoutMs?: number; + /** Max output tokens per request. Cloud providers only; ignored by Ollama. */ + maxTokens?: number; /** External cancellation (job abort). */ signal?: AbortSignal; /** @@ -277,6 +279,7 @@ async function chat(prompt: string, opts: GenericOptions): Promise { apiKey: opts.apiKey ?? "", model: opts.model, temperature: opts.temperature, + maxTokens: opts.maxTokens, timeoutMs: opts.timeoutMs, signal: opts.signal, }); diff --git a/src/subtitle-translate.ts b/src/subtitle-translate.ts index 2396ee1..ef5eaa2 100644 --- a/src/subtitle-translate.ts +++ b/src/subtitle-translate.ts @@ -1,11 +1,25 @@ import { Logger } from "./logger"; import { parseAssEvents, splitAssText, joinAssText, buildTranslatedAss, type AssEventLine } from "./ass-edit"; import { parseSrt, buildSrt } from "./srt-edit"; -import { translateBatch, type OllamaOptions } from "./ollama"; +import { translateBatch } from "./ollama"; import { resolveTranslateLang, normalizeTag, type TranslateLang } from "./translate-languages"; import { translateBatchGeneric, type GenericOptions, type TranslateItem } from "./ollama-generic"; import { createSemaphore, type Semaphore } from "./concurrency"; +/** + * True when a sign/song's visible text is a single character. Animated + * typesetting is often split into one event per character ("S", "H", "O", + * "P" as four events, sometimes duplicated per frame); a lone character + * carries no translatable meaning, so those are kept verbatim. Applies only + * to non-dialogue (sign/song) events. + */ +export function isSingleCharSign(visible: string): boolean { + const t = visible.trim(); + // Count code points, not UTF-16 units, so surrogate-pair characters + // (rare CJK, symbols) still count as one. + return t === "" || [...t].length === 1; +} + /** * Split a timed line sequence into translation chunks of roughly `batchSize`, * nudging each boundary to the largest pause nearby so a chunk never cuts @@ -149,13 +163,25 @@ async function translateAss(content: string, opts: TranslateContentOptions): Pro const isDialogue = opts.isDialogueStyle ?? (() => true); const units: Unit[] = []; + let skippedSigns = 0; for (const ev of events) { - if (dialogueOnly && !isDialogue(ev.style)) continue; + const dialogue = isDialogue(ev.style); + if (dialogueOnly && !dialogue) continue; const parts = splitAssText(ev.rawText); if (!parts.translatable) continue; + + if (!dialogue && isSingleCharSign(parts.visible)) { + skippedSigns++; + continue; + } + units.push({ key: ev.lineNo, startMs: ev.startMs, endMs: ev.endMs, visible: parts.visible, lead: parts.lead, name: ev.name || undefined }); } + if (skippedSigns > 0) { + Logger.info(`[translate] Skipped ${skippedSigns} single-word sign/song event(s) (animated typesetting)`); + } + if (units.length === 0) { Logger.info("[translate] ASS source has no translatable lines; emitting a copy"); return content; diff --git a/src/translate-step.ts b/src/translate-step.ts index 345a047..c63dd8c 100644 --- a/src/translate-step.ts +++ b/src/translate-step.ts @@ -3,10 +3,10 @@ import { readFileSync, writeFileSync, existsSync } from "fs"; import { Logger } from "./logger"; import { run } from "./process"; import type { JobSettings, SubtitleStreamInfo, SubtitleStyle } from "./types"; -import { detectSubtitleTrackType, buildSubtitleTrackName, isTextSubtitleCodec, sortSubtitleStreams } from "./tracks"; +import { detectSubtitleTrackType, buildSubtitleTrackName, isTextSubtitleCodec } from "./tracks"; import { dialogueStyleNames } from "./ass-classifier"; import { styleSrtAss, restyleAssDialogueFont } from "./ass-style"; -import { checkOllama, type OllamaOptions } from "./ollama"; +import { checkOllama } from "./ollama"; import { planTargetLanguages, translateSubtitleContent, type KeptSubDescriptor } from "./subtitle-translate"; import { createSemaphore } from "./concurrency"; import { resolveTranslateStrategy } from "./translate-provider"; @@ -261,6 +261,7 @@ export async function runTranslateStep(params: RunTranslateStepParams): Promise< source: prod.source, target: prod.target, numCtx: settings.translateNumCtx, + maxTokens: settings.translateMaxTokens, timeoutMs: settings.translateTimeoutMs, signal, }; diff --git a/src/types.ts b/src/types.ts index d6d429c..f04967e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -244,6 +244,8 @@ export interface JobSettings { translateNumCtx: number; /** Per-request timeout, ms. */ translateTimeoutMs: number; + /** Max output tokens per request (cloud providers). Default 8192. */ + translateMaxTokens?: number; /** Max concurrent in-flight Ollama requests (all languages + chunks). Keep <= server OLLAMA_NUM_PARALLEL. Default 1. */ translateConcurrency?: number; /** May translation overlap the video encode? "auto" overlaps only when Ollama is NOT on a loopback address. Default "auto". */ From e4e7a67ff942444432518ed92c6fe46f8af36445 Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Sat, 4 Jul 2026 14:43:15 +0200 Subject: [PATCH 06/13] Make translateConcurrency value configurable thru UI --- src/config.ts | 3 ++- src/settings-code.ts | 1 + src/store.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 897989b..f71236f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -92,8 +92,9 @@ const DEFAULT_JOB_SETTINGS: JobSettings = { translateBatchSize: 40, translateSignsSongs: true, translateNumCtx: 8192, - translateTimeoutMs: 120000, + translateTimeoutMs: 300_000, translateMaxTokens: 8192, + translateConcurrency: 1, vsFilters: [], }; diff --git a/src/settings-code.ts b/src/settings-code.ts index 97cdaca..1aa17a0 100644 --- a/src/settings-code.ts +++ b/src/settings-code.ts @@ -125,6 +125,7 @@ const BASELINE: JobSettings = { translateSignsSongs: true, translateNumCtx: 8192, translateTimeoutMs: 120000, + translateConcurrency: 1, vsFilters: [], }; diff --git a/src/store.ts b/src/store.ts index 66b5e2b..ca378da 100644 --- a/src/store.ts +++ b/src/store.ts @@ -275,6 +275,7 @@ const SETTINGS_SANITIZERS: { [K in keyof JobSettings]?: Sanitizer } = { translateSignsSongs: bool, translateNumCtx: intIn(512, 131072), translateTimeoutMs: intIn(1000, 3600000), + translateConcurrency: intIn(1, 16), }; function sanitizeSettingsInto(target: JobSettings, partial: Partial): void { From 9eac3c818d91d8e63bdd1f58e69c08e30205ab23 Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Sun, 5 Jul 2026 21:54:50 +0200 Subject: [PATCH 07/13] Better handle language translation of animated typesetting --- src/subtitle-translate.ts | 32 ++++++- tests/translate-dedup.test.ts | 170 ++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 tests/translate-dedup.test.ts diff --git a/src/subtitle-translate.ts b/src/subtitle-translate.ts index ef5eaa2..b480606 100644 --- a/src/subtitle-translate.ts +++ b/src/subtitle-translate.ts @@ -164,6 +164,12 @@ async function translateAss(content: string, opts: TranslateContentOptions): Pro const units: Unit[] = []; let skippedSigns = 0; + let dedupedSigns = 0; + + const leadByKey = new Map(); // per-line leads (moved up) + const signFirstKey = new Map(); // visible -> first unit's lineNo + const signDuplicates = new Map(); // visible -> later lineNos + for (const ev of events) { const dialogue = isDialogue(ev.style); if (dialogueOnly && !dialogue) continue; @@ -175,11 +181,27 @@ async function translateAss(content: string, opts: TranslateContentOptions): Pro continue; } + leadByKey.set(ev.lineNo, parts.lead); + + // Animated typesetting often repeats the same text once per frame with + // only \pos/\clip changing; translate the text once and fan it out. + if (!dialogue) { + const firstKey = signFirstKey.get(parts.visible); + if (firstKey !== undefined) { + let dups = signDuplicates.get(parts.visible); + if (!dups) signDuplicates.set(parts.visible, (dups = [])); + dups.push(ev.lineNo); + dedupedSigns++; + continue; + } + signFirstKey.set(parts.visible, ev.lineNo); + } + units.push({ key: ev.lineNo, startMs: ev.startMs, endMs: ev.endMs, visible: parts.visible, lead: parts.lead, name: ev.name || undefined }); } if (skippedSigns > 0) { - Logger.info(`[translate] Skipped ${skippedSigns} single-word sign/song event(s) (animated typesetting)`); + Logger.info(`[translate] Deduplicated ${dedupedSigns} repeated sign/song event(s); translating ${signFirstKey.size} unique sign texts once each`); } if (units.length === 0) { @@ -187,7 +209,6 @@ async function translateAss(content: string, opts: TranslateContentOptions): Pro return content; } - const leadByKey = new Map(); for (const u of units) leadByKey.set(u.key, u.lead); const translatedVisible = await translateUnits(units, opts); @@ -196,6 +217,13 @@ async function translateAss(content: string, opts: TranslateContentOptions): Pro for (const [key, visible] of translatedVisible) { newTextByLineNo.set(key, joinAssText(leadByKey.get(key) ?? "", visible)); } + for (const [text, dups] of signDuplicates) { + const translated = translatedVisible.get(signFirstKey.get(text)!); + if (translated === undefined) continue; + for (const lineNo of dups) { + newTextByLineNo.set(lineNo, joinAssText(leadByKey.get(lineNo) ?? "", translated)); + } + } return buildTranslatedAss(content, newTextByLineNo, events as AssEventLine[]); } diff --git a/tests/translate-dedup.test.ts b/tests/translate-dedup.test.ts new file mode 100644 index 0000000..856e32d --- /dev/null +++ b/tests/translate-dedup.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, afterEach } from "bun:test"; +import { translateSubtitleContent, type TranslateContentOptions } from "../src/subtitle-translate"; +import { buildAss, DEFAULT_STYLE_LINE, SIGN_STYLE_LINE } from "./fixtures/ass"; + +const EN = { name: "English", code: "en" }; +const SL = { name: "Slovenian", code: "sl" }; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +/** + * Mock the Ollama chat endpoint: "translates" by uppercasing each line and + * records every line the model was asked to translate into `sent`. + */ +function mockTranslate(sent: string[]) { + globalThis.fetch = (async (_url: string, init: any) => { + const prompt: string = JSON.parse(init.body).messages[0].content; + const block = prompt.split("Slovenian:\n\n\n")[1]!; + const lines = block.split("\n"); + sent.push(...lines); + const content = lines.map((l) => l.toUpperCase()).join("\n"); + return new Response(JSON.stringify({ message: { content } }), { status: 200 }); + }) as unknown as typeof fetch; +} + +function opts(over: Partial = {}): TranslateContentOptions { + return { + format: "ass", + batchSize: 100, + translateSignsSongs: true, + strategy: "translategemma", + isDialogueStyle: (s) => s === "Default", + ollama: { url: "http://localhost:11434", model: "translategemma:12b", source: EN, target: SL } as any, + ...over, + }; +} + +const STYLES = [DEFAULT_STYLE_LINE, SIGN_STYLE_LINE]; + +describe("translateAss sign/song deduplication", () => { + it("translates repeated sign text once and fans it out, preserving each line's own lead", async () => { + const ass = buildAss({ + styles: STYLES, + events: [ + "Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello there", + "Dialogue: 0,0:23:17.93,0:23:22.26,Signs,,0,0,0,fx,{\\pos(960,1050)\\clip(355,1048,1567,1051)}Rock", + "Dialogue: 0,0:23:17.93,0:23:22.26,Signs,,0,0,0,fx,{\\pos(960,1050)\\clip(355,1051,1567,1052)}Rock", + "Dialogue: 0,0:23:17.93,0:23:22.26,Signs,,0,0,0,fx,{\\pos(960,1050)\\clip(355,1052,1567,1053)}Rock", + ], + }); + + const sent: string[] = []; + mockTranslate(sent); + + const out = await translateSubtitleContent(ass, opts()); + + // The model saw the sign text exactly once (plus the dialogue line). + expect(sent.filter((l) => l === "Rock").length).toBe(1); + expect(sent.length).toBe(2); + + // All three sign lines carry the shared translation, each with its OWN lead. + expect(out).toContain("{\\pos(960,1050)\\clip(355,1048,1567,1051)}ROCK"); + expect(out).toContain("{\\pos(960,1050)\\clip(355,1051,1567,1052)}ROCK"); + expect(out).toContain("{\\pos(960,1050)\\clip(355,1052,1567,1053)}ROCK"); + expect(out).toContain(",Default,,0,0,0,,HELLO THERE"); + // No untranslated leftovers. + expect(out).not.toContain("}Rock"); + }); + + it("does not dedupe signs with different visible text", async () => { + const ass = buildAss({ + styles: STYLES, + events: ["Dialogue: 0,0:00:01.00,0:00:03.00,Signs,,0,0,0,,{\\pos(1,1)}Rock", "Dialogue: 0,0:00:04.00,0:00:06.00,Signs,,0,0,0,,{\\pos(2,2)}Plant"], + }); + + const sent: string[] = []; + mockTranslate(sent); + + const out = await translateSubtitleContent(ass, opts()); + expect(sent.sort()).toEqual(["Plant", "Rock"]); + expect(out).toContain("{\\pos(1,1)}ROCK"); + expect(out).toContain("{\\pos(2,2)}PLANT"); + }); + + it("does NOT dedupe identical dialogue lines (context may differ)", async () => { + const ass = buildAss({ + styles: STYLES, + events: ["Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Same line", "Dialogue: 0,0:10:00.00,0:10:02.00,Default,,0,0,0,,Same line"], + }); + + const sent: string[] = []; + mockTranslate(sent); + + await translateSubtitleContent(ass, opts()); + expect(sent.filter((l) => l === "Same line").length).toBe(2); + }); + + it("still skips single-char sign events verbatim (never sent, never deduped)", async () => { + const ass = buildAss({ + styles: STYLES, + events: [ + "Dialogue: 0,0:00:01.00,0:00:03.00,Signs,,0,0,0,,{\\pos(1,1)}A", + "Dialogue: 0,0:00:01.00,0:00:03.00,Signs,,0,0,0,,{\\pos(2,2)}A", + "Dialogue: 0,0:00:05.00,0:00:07.00,Default,,0,0,0,,Real dialogue", + ], + }); + + const sent: string[] = []; + mockTranslate(sent); + + const out = await translateSubtitleContent(ass, opts()); + expect(sent).toEqual(["Real dialogue"]); + expect(out).toContain("{\\pos(1,1)}A"); + expect(out).toContain("{\\pos(2,2)}A"); + }); + + it("reports progress totals based on the deduped unit count", async () => { + const ass = buildAss({ + styles: STYLES, + events: [ + "Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello", + "Dialogue: 0,0:00:04.00,0:00:06.00,Signs,,0,0,0,,{\\pos(1,1)}Rock", + "Dialogue: 0,0:00:04.00,0:00:06.00,Signs,,0,0,0,,{\\pos(1,2)}Rock", + "Dialogue: 0,0:00:04.00,0:00:06.00,Signs,,0,0,0,,{\\pos(1,3)}Rock", + "Dialogue: 0,0:00:04.00,0:00:06.00,Signs,,0,0,0,,{\\pos(1,4)}Rock", + ], + }); + + const sent: string[] = []; + mockTranslate(sent); + + let lastTotal = -1; + let lastDone = -1; + await translateSubtitleContent( + ass, + opts({ + onProgress: (done, total) => { + lastDone = done; + lastTotal = total; + }, + }), + ); + + // 5 events collapse to 2 translation units (1 dialogue + 1 unique sign). + expect(lastTotal).toBe(2); + expect(lastDone).toBe(2); + }); + + it("dedup never applies when translateSignsSongs is off", async () => { + const ass = buildAss({ + styles: STYLES, + events: [ + "Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello", + "Dialogue: 0,0:00:04.00,0:00:06.00,Signs,,0,0,0,,{\\pos(1,1)}Rock", + "Dialogue: 0,0:00:04.00,0:00:06.00,Signs,,0,0,0,,{\\pos(1,2)}Rock", + ], + }); + + const sent: string[] = []; + mockTranslate(sent); + + const out = await translateSubtitleContent(ass, opts({ translateSignsSongs: false })); + expect(sent).toEqual(["Hello"]); + // Sign lines untouched, original text intact. + expect(out).toContain("{\\pos(1,1)}Rock"); + expect(out).toContain("{\\pos(1,2)}Rock"); + }); +}); From 8fb8c478c8b15bebc325735db749203819f7d9a2 Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Mon, 6 Jul 2026 17:38:57 +0200 Subject: [PATCH 08/13] Improve subtitle translation of signs & songs --- src/ollama-generic.ts | 17 ++- src/subtitle-translate.ts | 255 ++++++++++++++++++++++++++++++---- tests/noise-signs.test.ts | 220 +++++++++++++++++++++++++++++ tests/sign-clustering.test.ts | 226 ++++++++++++++++++++++++++++++ 4 files changed, 687 insertions(+), 31 deletions(-) create mode 100644 tests/noise-signs.test.ts create mode 100644 tests/sign-clustering.test.ts diff --git a/src/ollama-generic.ts b/src/ollama-generic.ts index 0b0d349..d9f9d8c 100644 --- a/src/ollama-generic.ts +++ b/src/ollama-generic.ts @@ -180,17 +180,30 @@ export function buildGenericInstruction(source: TranslateLang, target: Translate ``, `Requirements:`, `- Preserve all meaning without additions or omissions.`, + `- Do not embellish, explain, intensify, simplify, censor, or add personality beyond what is present in the source.`, `- Preserve each character's tone, humour and emotional intensity.`, + `- Preserve profanity, insults, vulgar language, crude jokes and offensive tone when present. Do not censor, soften, sanitize, or replace them with polite language.`, + `- Translate profanity by matching the intended intensity and naturalness in ${target.name}, not necessarily word-for-word. Do not make it stronger or weaker than the source.`, + `- Translate idioms, jokes, exclamations and strong reactions into the closest natural ${target.name} equivalent. Do not translate them literally if that would sound unnatural.`, `- Use natural conversational language.`, + `- Keep the level of formality, politeness, intimacy, disrespect, age difference and social status natural for ${target.name}. Be consistent in how characters address each other.`, + `- For languages with formal/informal "you" distinctions, choose the form that fits the relationship and scene context, then keep it consistent unless the relationship or situation changes.`, `- Maintain consistent names, terminology and forms of address across entries.`, + `- Treat recurring skill names, abilities, magic terms, fictional terms, ranks, titles, locations and UI/system messages as terminology. Translate them consistently across the batch.`, + `- Preserve proper names unless they already have a standard translation in ${target.name} or are clearly meant to be translated titles/labels.`, + `- Preserve source honorifics such as "-san", "-sama", "-kun" when they are present and meaningful. Translate ordinary titles such as "king", "duke", "captain", "teacher" naturally into ${target.name}.`, `- Each entry has an "id", an optional "name" (the speaking character), and "text".`, - ` Use "name" to know who is speaking and to keep their voice and forms of address consistent.`, - `- Use the surrounding entries as context for pronouns, tone and continuity.`, + ` Use "name" to know who is speaking and to keep their voice, terminology and forms of address consistent.`, + `- Use the surrounding entries as context for pronouns, tone, terminology and continuity.`, `- Preserve every "id" exactly.`, `- Preserve protected formatting verbatim: any leading "{...}" override block and every "\\N" line break must appear unchanged in the output text.`, `- Do not merge, split, reorder, add or remove entries.`, `- Return ONLY a JSON array of objects {"id": "...", "text": "..."}, one per input entry, with no commentary, no explanations and no markdown fences.`, `- In the JSON output, remember "\\N" must be escaped as "\\\\N" inside string values.`, + `- Fix obvious typos, spelling mistakes and accidental grammar errors only when they are not intentional character speech, dialect, slang, accent, childish speech, drunken speech, comedic mistakes, or otherwise part of the characterization.`, + `- If text appears intentionally misspelled, scrambled, glitched, childish, drunken, accented, dialectal, or comedic, preserve that effect in the translation instead of correcting it.`, + `- If several nearby entries show repeated typo-like variations of the same on-screen text, treat them as an intentional visual effect and keep an equivalent broken/glitched effect.`, + `- Before returning, silently check that every input id appears exactly once, no entries are missing, and all protected formatting and "\\N" line breaks are preserved.`, ].join("\n"); } diff --git a/src/subtitle-translate.ts b/src/subtitle-translate.ts index b480606..9682627 100644 --- a/src/subtitle-translate.ts +++ b/src/subtitle-translate.ts @@ -156,19 +156,190 @@ async function translateUnits(units: Unit[], opts: TranslateContentOptions): Pro return out; } +/** + * True when a sign's visible text is random-letter ASCII noise (grain/static + * noise-font typesetting). Structural checks apply per \N segment; the + * statistical checks (case-flip rate, vowel ratio) are computed over all + * segments combined — per-segment samples are too small to be reliable. + * Non-ASCII scripts always return false (fails closed). + */ +export function isNoiseSign(visible: string, durationMs?: number): boolean { + const segs = visible + .split(/\\N/i) + .map((s) => s.trim()) + .filter(Boolean); + if (segs.length === 0) return false; + + const frameLength = durationMs !== undefined && durationMs <= 100; + const minLen = frameLength ? 8 : 12; + + for (const seg of segs) { + if (seg.length < minLen || !/^[A-Za-z]+$/.test(seg)) return false; + } + + let flips = 0; + let pairs = 0; + for (const seg of segs) { + for (let i = 1; i < seg.length; i++) { + pairs++; + if (seg[i - 1]! >= "a" !== seg[i]! >= "a") flips++; + } + } + const flipRate = pairs > 0 ? flips / pairs : 0; + const all = segs.join(""); + const vowelRatio = (all.match(/[aeiou]/gi)?.length ?? 0) / all.length; + + return flipRate >= (frameLength ? 0.2 : 0.3) && vowelRatio <= (frameLength ? 0.32 : 0.28); +} + +/** + * Find per-frame "churn" effect events (grain, static, glitch text): runs of + * consecutive same-style non-dialogue events that are frame-length, temporally + * contiguous, and all carry distinct text. Readable text can't change every + * frame in any script, so this is language-agnostic. Returns lineNos to skip. + */ +export function findFrameChurnEvents( + events: AssEventLine[], + isDialogue: (style: string) => boolean, + opts = { maxEventMs: 100, maxGapMs: 50, minRun: 5 }, +): Set { + const skip = new Set(); + const byGroup = new Map(); + + for (const ev of events) { + if (isDialogue(ev.style)) continue; + if (ev.endMs - ev.startMs > opts.maxEventMs) continue; + const key = `${ev.style}\u0000${ev.name}`; + let arr = byGroup.get(key); + if (!arr) byGroup.set(key, (arr = [])); + arr.push(ev); + } + + for (const arr of byGroup.values()) { + arr.sort((a, b) => a.startMs - b.startMs); + let run: AssEventLine[] = []; + const flush = () => { + const texts = new Set(run.map((e) => splitAssText(e.rawText).visible)); + const uniqueRatio = texts.size / run.length; + if (run.length >= opts.minRun && uniqueRatio >= 0.5) { + for (const e of run) skip.add(e.lineNo); + } + run = []; + }; + for (const ev of arr) { + const prev = run[run.length - 1]; + if (prev && ev.startMs - prev.endMs > opts.maxGapMs) flush(); + run.push(ev); + } + flush(); + } + return skip; +} + +/** + * Distance between two sign texts counting only letter↔letter substitutions + * (scramble/typewriter noise). Null when lengths differ or any differing + * position involves a digit, punctuation, or non-ASCII character - those are + * real content changes ("Floor 1" vs "Floor 2" must never merge). + */ +export function scrambleDistance(a: string, b: string, max: number): number | null { + if (a.length !== b.length) return null; + let d = 0; + for (let i = 0; i < a.length; i++) { + if (a[i] === b[i]) continue; + if (!/[a-z]/i.test(a[i]!) || !/[a-z]/i.test(b[i]!)) return null; + if (++d > max) return null; + } + return d; +} + +export interface SignEventInput { + lineNo: number; + startMs: number; + endMs: number; + visible: string; + /** Cluster grouping key, typically `${style}\u0000${name}`. */ + group: string; +} + +export interface SignCluster { + /** Longest-duration member (ties → latest); its text is what gets translated. */ + representative: SignEventInput; + members: SignEventInput[]; +} + +/** + * Group sign events into clusters sharing one translation. Exact text repeats + * merge unconditionally (preserves the old dedup semantics for animated + * typesetting); scramble variants merge only within `windowMs` of the cluster + * and within `maxSubs` letter substitutions of its first-seen text. + */ +export function clusterSignEvents(signs: SignEventInput[], opts = { maxSubs: 2, windowMs: 5000 }): SignCluster[] { + interface Open { + anchor: string; + lastEndMs: number; + members: SignEventInput[]; + } + const byGroup = new Map(); + for (const s of signs) { + let arr = byGroup.get(s.group); + if (!arr) byGroup.set(s.group, (arr = [])); + arr.push(s); + } + + const clusters: SignCluster[] = []; + for (const arr of byGroup.values()) { + arr.sort((x, y) => x.startMs - y.startMs || x.endMs - y.endMs); + const open: Open[] = []; + const exactByText = new Map(); + + for (const ev of arr) { + let hit = exactByText.get(ev.visible); + if (!hit) { + for (const c of open) { + if (ev.startMs - c.lastEndMs > opts.windowMs) continue; + if (scrambleDistance(ev.visible, c.anchor, opts.maxSubs) !== null) { + hit = c; + break; + } + } + } + if (hit) { + hit.members.push(ev); + hit.lastEndMs = Math.max(hit.lastEndMs, ev.endMs); + if (!exactByText.has(ev.visible)) exactByText.set(ev.visible, hit); + } else { + const c: Open = { anchor: ev.visible, lastEndMs: ev.endMs, members: [ev] }; + open.push(c); + exactByText.set(ev.visible, c); + } + } + + for (const c of open) { + let rep = c.members[0]!; + for (const m of c.members) { + const dm = m.endMs - m.startMs; + const dr = rep.endMs - rep.startMs; + if (dm > dr || (dm === dr && m.startMs > rep.startMs)) rep = m; + } + clusters.push({ representative: rep, members: c.members }); + } + } + return clusters; +} + async function translateAss(content: string, opts: TranslateContentOptions): Promise { const { events } = parseAssEvents(content); const dialogueOnly = !opts.translateSignsSongs; const isDialogue = opts.isDialogueStyle ?? (() => true); + const READABLE_MS = 500; const units: Unit[] = []; + const leadByKey = new Map(); + const evByLineNo = new Map(); + const signInputs: SignEventInput[] = []; let skippedSigns = 0; - let dedupedSigns = 0; - - const leadByKey = new Map(); // per-line leads (moved up) - const signFirstKey = new Map(); // visible -> first unit's lineNo - const signDuplicates = new Map(); // visible -> later lineNos for (const ev of events) { const dialogue = isDialogue(ev.style); @@ -176,32 +347,60 @@ async function translateAss(content: string, opts: TranslateContentOptions): Pro const parts = splitAssText(ev.rawText); if (!parts.translatable) continue; - if (!dialogue && isSingleCharSign(parts.visible)) { + if (!dialogue && (isSingleCharSign(parts.visible) || isNoiseSign(parts.visible, ev.endMs - ev.startMs))) { skippedSigns++; continue; } leadByKey.set(ev.lineNo, parts.lead); - // Animated typesetting often repeats the same text once per frame with - // only \pos/\clip changing; translate the text once and fan it out. - if (!dialogue) { - const firstKey = signFirstKey.get(parts.visible); - if (firstKey !== undefined) { - let dups = signDuplicates.get(parts.visible); - if (!dups) signDuplicates.set(parts.visible, (dups = [])); - dups.push(ev.lineNo); - dedupedSigns++; - continue; - } - signFirstKey.set(parts.visible, ev.lineNo); + if (dialogue) { + units.push({ key: ev.lineNo, startMs: ev.startMs, endMs: ev.endMs, visible: parts.visible, lead: parts.lead, name: ev.name || undefined }); + } else { + evByLineNo.set(ev.lineNo, ev); + signInputs.push({ lineNo: ev.lineNo, startMs: ev.startMs, endMs: ev.endMs, visible: parts.visible, group: `${ev.style}\u0000${ev.name}` }); } + } - units.push({ key: ev.lineNo, startMs: ev.startMs, endMs: ev.endMs, visible: parts.visible, lead: parts.lead, name: ev.name || undefined }); + // Exact repeats and scramble variants collapse into clusters, each + // translated once from its most readable (longest-held) member. + const clusters = clusterSignEvents(signInputs); + + // Clusters with no readably-held member are flicker: per-frame text nobody + // can read (e.g. non-ASCII grain isNoiseSign can't see). Those go to the + // language-agnostic churn detector; everything flagged stays verbatim. + const keptClusters: SignCluster[] = []; + const flickerEvents: AssEventLine[] = []; + const flickerClusters: SignCluster[] = []; + for (const c of clusters) { + if (c.members.some((m) => m.endMs - m.startMs >= READABLE_MS)) { + keptClusters.push(c); + } else { + flickerClusters.push(c); + for (const m of c.members) flickerEvents.push(evByLineNo.get(m.lineNo)!); + } + } + const churn = findFrameChurnEvents(flickerEvents, isDialogue); + for (const c of flickerClusters) { + if (c.members.every((m) => churn.has(m.lineNo))) skippedSigns += c.members.length; + else keptClusters.push(c); // short but legit sign (e.g. a lone 300ms stamp) + } + + for (const c of keptClusters) { + const rep = c.representative; + units.push({ + key: rep.lineNo, + startMs: rep.startMs, + endMs: rep.endMs, + visible: rep.visible, + lead: leadByKey.get(rep.lineNo) ?? "", + name: evByLineNo.get(rep.lineNo)?.name || undefined, + }); } + units.sort((a, b) => a.startMs - b.startMs); // planChunks expects chronological order - if (skippedSigns > 0) { - Logger.info(`[translate] Deduplicated ${dedupedSigns} repeated sign/song event(s); translating ${signFirstKey.size} unique sign texts once each`); + if (skippedSigns > 0 || signInputs.length > keptClusters.length) { + Logger.info(`[translate] Skipped ${skippedSigns} noise/churn sign event(s); ${keptClusters.length} sign cluster(s) translated once each`); } if (units.length === 0) { @@ -209,23 +408,21 @@ async function translateAss(content: string, opts: TranslateContentOptions): Pro return content; } - for (const u of units) leadByKey.set(u.key, u.lead); - const translatedVisible = await translateUnits(units, opts); const newTextByLineNo = new Map(); for (const [key, visible] of translatedVisible) { newTextByLineNo.set(key, joinAssText(leadByKey.get(key) ?? "", visible)); } - for (const [text, dups] of signDuplicates) { - const translated = translatedVisible.get(signFirstKey.get(text)!); - if (translated === undefined) continue; - for (const lineNo of dups) { - newTextByLineNo.set(lineNo, joinAssText(leadByKey.get(lineNo) ?? "", translated)); + for (const c of keptClusters) { + const t = translatedVisible.get(c.representative.lineNo); + if (t === undefined) continue; + for (const m of c.members) { + newTextByLineNo.set(m.lineNo, joinAssText(leadByKey.get(m.lineNo) ?? "", t)); } } - return buildTranslatedAss(content, newTextByLineNo, events as AssEventLine[]); + return buildTranslatedAss(content, newTextByLineNo, events); } async function translateSrt(content: string, opts: TranslateContentOptions): Promise { diff --git a/tests/noise-signs.test.ts b/tests/noise-signs.test.ts new file mode 100644 index 0000000..1c63823 --- /dev/null +++ b/tests/noise-signs.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it, afterEach } from "bun:test"; +import { isNoiseSign, findFrameChurnEvents, translateSubtitleContent, type TranslateContentOptions } from "../src/subtitle-translate"; +import { parseAssEvents } from "../src/ass-edit"; +import { buildAss, DEFAULT_STYLE_LINE, SIGN_STYLE_LINE } from "./fixtures/ass"; + +const EN = { name: "English", code: "en" }; +const SL = { name: "Slovenian", code: "sl" }; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +/** + * Mock the Ollama chat endpoint: "translates" by uppercasing each line and + * records every line the model was asked to translate into `sent`. + */ +function mockTranslate(sent: string[]) { + globalThis.fetch = (async (_url: string, init: any) => { + const prompt: string = JSON.parse(init.body).messages[0].content; + const block = prompt.split("Slovenian:\n\n\n")[1]!; + const lines = block.split("\n"); + sent.push(...lines); + const content = lines.map((l) => l.toUpperCase()).join("\n"); + return new Response(JSON.stringify({ message: { content } }), { status: 200 }); + }) as unknown as typeof fetch; +} + +function opts(over: Partial = {}): TranslateContentOptions { + return { + format: "ass", + batchSize: 100, + translateSignsSongs: true, + strategy: "translategemma", + isDialogueStyle: (s) => s === "Default", + ollama: { url: "http://localhost:11434", model: "translategemma:12b", source: EN, target: SL } as any, + ...over, + }; +} + +const STYLES = [DEFAULT_STYLE_LINE, SIGN_STYLE_LINE]; +const isDialogue = (s: string) => s === "Default"; + +const GRAIN = [ + "lutIpSLjgSlpDQd\\NpCsQcxPaNjjCuqu\\NCaoMzPETXEHiNyH\\NbMrNIQRUnysTKwy\\NgKmbnHToiMPwmhr\\NYtUoSCqZlPPOikw\\NfGVKZTlqIPCHyEo", + "aHjokYqMNEuFwCC\\NXOENeCCUfsyaJll\\NotbKPBGtuxvSnaS\\NDHkneXQbnvsWnyV\\NnzEzmQPJinAUzYr\\NeWNXEyAOHWsLeEL\\NhYHDRCwMNkpbHpY", + "jGcpwbNlkKnxzYB\\NbSKlbHqpaqGcNzQ\\NaqIZKcRqUorpMgn\\NAimxjGJZzrjhRMs\\NYrqTNpMUnoWyVqW\\NKysVbWMrzepXFQs\\NYqLlGaGvXTdRqXj", + "DefspzwDbZvBIaT\\NyWBhkNYtYdxDzGs\\NcgiZeUDNjCoHVeo\\NkGTDXAIbMQCUddb\\NdCbUlYUzfvadRNc\\NzxvWWDlQsZgZDtY\\NekafTkBmtdYEyFy", + "YYBjtCcYQgQjHeE\\NTNWPtbCAnHNONzo\\NNlGXgQRYZAqMBEw\\NGaPlKVwXTqiBuDh\\NQRFFqYDdltiNsNV\\NXyvEYsaoWreWNZR\\NaTnHNpNCRouPMpr", + "szJPjDSqDYlNUhN\\NCKonvoqWeCyIvBb\\NbVKjllXNjqfuGAz\\NrOZVOqXDuzYaQdY\\NeUFqFiGGecbGpJo\\NsLNrisePWBgGtGM\\NgLYQMrdcNyAprPz", +]; + +/** Format centiseconds as an ASS timecode (H:MM:SS.CC). */ +function cs(t: number): string { + const c = t % 100; + const s = Math.floor(t / 100) % 60; + const m = Math.floor(t / 6000) % 60; + const h = Math.floor(t / 360000); + return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(c).padStart(2, "0")}`; +} + +/** Build back-to-back Dialogue lines of `durCs` centiseconds each. */ +function churnLines(texts: string[], startCs: number, durCs = 4, style = "Signs", name = "Grain"): string[] { + return texts.map((t, i) => `Dialogue: 1,${cs(startCs + i * durCs)},${cs(startCs + (i + 1) * durCs)},${style},${name},0,0,0,,{\\pos(675,259)}${t}`); +} + +function eventsOf(lines: string[]) { + return parseAssEvents(buildAss({ styles: STYLES, events: lines })).events; +} + +describe("isNoiseSign", () => { + it("flags real multi-line grain lines, including vowel-rich-segment outliers", () => { + for (const g of GRAIN) { + expect(isNoiseSign(g)).toBe(true); + } + }); + + it("flags a single long gibberish token; short ones only at frame-length durations", () => { + expect(isNoiseSign("lutIpSLjgSlpDQd")).toBe(true); // 15 chars, no duration needed + expect(isNoiseSign("lutIpSLj")).toBe(false); // 8 < 12 without duration corroboration + expect(isNoiseSign("lutIpSLj", 40)).toBe(true); // frame-length loosens minLen to 8 + expect(isNoiseSign("lutIpSLj", 3000)).toBe(false); // long event: strict thresholds + }); + + it("never flags real sign text", () => { + expect(isNoiseSign("Magistone\\NOre")).toBe(false); // short word segments + expect(isNoiseSign("Notice\\NPhysical Damage Taken \\N10%")).toBe(false); // spaces + digits + expect(isNoiseSign("Water Pressure Propulsion")).toBe(false); // spaces + expect(isNoiseSign("SHOPOPENINGSOON")).toBe(false); // all caps: zero case flips + expect(isNoiseSign("SelfRegeneration")).toBe(false); // CamelCase: low flip rate + expect(isNoiseSign("Donaudampfschifffahrt")).toBe(false); // long compound, stable case + }); + + it("fails closed for non-ASCII scripts (Japanese, Cyrillic, accented Latin)", () => { + expect(isNoiseSign("営業中\\N準備中")).toBe(false); + expect(isNoiseSign("МагазинОткрытКруглосуточно")).toBe(false); + expect(isNoiseSign("PâtisserieOuverte")).toBe(false); + }); + + it("is inert for romaji: high flip rate alone is not enough (vowel-dense)", () => { + expect(isNoiseSign("ShingekiNoKyojin")).toBe(false); + }); + + it("returns false for empty or break-only text", () => { + expect(isNoiseSign("")).toBe(false); + expect(isNoiseSign(" \\N ")).toBe(false); + }); +}); + +describe("findFrameChurnEvents", () => { + it("flags a contiguous frame-length run of unique-text sign events", () => { + const events = eventsOf(churnLines(GRAIN, 100)); + const skip = findFrameChurnEvents(events, isDialogue); + expect(skip.size).toBe(6); + for (const ev of events) expect(skip.has(ev.lineNo)).toBe(true); + }); + + it("is language-agnostic: flags kana churn that isNoiseSign cannot see", () => { + const kana = ["あかさたなはまやらわ", "いきしちにひみりゐん", "うくすつぬふむゆるぼ", "えけせてねへめれゑぞ", "おこそとのほもよろを"]; + for (const k of kana) expect(isNoiseSign(k, 40)).toBe(false); // ASCII heuristic is blind here + const skip = findFrameChurnEvents(eventsOf(churnLines(kana, 100)), isDialogue); + expect(skip.size).toBe(5); // ...but the churn detector is not + }); + + it("does not flag constant-text frame animation (that is dedup's job)", () => { + const skip = findFrameChurnEvents(eventsOf(churnLines(Array(6).fill("Rock"), 100)), isDialogue); + expect(skip.size).toBe(0); + }); + + it("does not flag runs shorter than minRun", () => { + const skip = findFrameChurnEvents(eventsOf(churnLines(GRAIN.slice(0, 4), 100)), isDialogue); + expect(skip.size).toBe(0); + }); + + it("splits runs at temporal gaps larger than maxGapMs", () => { + // Two runs of 3, ~880ms apart: each is below minRun once split. + const lines = [...churnLines(GRAIN.slice(0, 3), 100), ...churnLines(GRAIN.slice(3, 6), 200)]; + const skip = findFrameChurnEvents(eventsOf(lines), isDialogue); + expect(skip.size).toBe(0); + }); + + it("groups by style+name so interleaved effects are tracked separately", () => { + // A 6-event Grain run with a second effect ("Static", 3 events) woven + // into the same time range: Grain is flagged, the short run is not. + const lines = [ + ...churnLines(GRAIN, 100, 4, "Signs", "Grain"), + ...churnLines(["QwErTyUiOpAsDf", "ZxCvBnMqWeRtYu", "PlMkOiJnUhBygv"], 102, 4, "Signs", "Static"), + ]; + const skip = findFrameChurnEvents(eventsOf(lines), isDialogue); + expect(skip.size).toBe(6); + }); + + it("ignores dialogue-styled events and events longer than maxEventMs", () => { + const dlg = findFrameChurnEvents(eventsOf(churnLines(GRAIN, 100, 4, "Default")), isDialogue); + expect(dlg.size).toBe(0); + + const long = findFrameChurnEvents(eventsOf(churnLines(GRAIN, 100, 300)), isDialogue); // 3s each + expect(long.size).toBe(0); + }); +}); + +describe("translateAss noise filtering (integration)", () => { + it("never sends churn/noise events to the model and preserves them verbatim", async () => { + const ass = buildAss({ + styles: STYLES, + events: [ + "Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello there", + "Dialogue: 0,0:00:05.00,0:00:08.00,Signs,,0,0,0,,{\\pos(2,2)}Magistone Ore", + // Per-frame churn run: caught by findFrameChurnEvents (and isNoiseSign). + ...churnLines(GRAIN, 1000), + // Isolated 3s noise overlay: no run to detect - only isNoiseSign catches it. + // If either filter regresses, the multi-line \N text also breaks the + // batch line count, so this fails loudly rather than silently. + `Dialogue: 0,0:00:20.00,0:00:23.00,Signs,Grain,0,0,0,,{\\pos(3,3)}${GRAIN[0]}`, + ], + }); + + const sent: string[] = []; + mockTranslate(sent); + + const out = await translateSubtitleContent(ass, opts()); + + // The model only ever saw the dialogue line and the real sign. + expect(sent.sort()).toEqual(["Hello there", "Magistone Ore"]); + + // Translated lines landed; noise events survive byte-identical. + expect(out).toContain(",Default,,0,0,0,,HELLO THERE"); + expect(out).toContain("{\\pos(2,2)}MAGISTONE ORE"); + for (const g of GRAIN) { + expect(out).toContain(`{\\pos(675,259)}${g}`); + } + expect(out).toContain(`{\\pos(3,3)}${GRAIN[0]}`); + }); + + it("reports progress totals excluding filtered noise events", async () => { + const ass = buildAss({ + styles: STYLES, + events: ["Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello", ...churnLines(GRAIN, 1000)], + }); + + const sent: string[] = []; + mockTranslate(sent); + + let lastDone = -1; + let lastTotal = -1; + await translateSubtitleContent( + ass, + opts({ + onProgress: (done, total) => { + lastDone = done; + lastTotal = total; + }, + }), + ); + + // 7 events collapse to a single translation unit (the dialogue line). + expect(lastTotal).toBe(1); + expect(lastDone).toBe(1); + }); +}); diff --git a/tests/sign-clustering.test.ts b/tests/sign-clustering.test.ts new file mode 100644 index 0000000..6bf3c3b --- /dev/null +++ b/tests/sign-clustering.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it, afterEach } from "bun:test"; +import { scrambleDistance, clusterSignEvents, translateSubtitleContent, type SignEventInput, type TranslateContentOptions } from "../src/subtitle-translate"; +import { buildAss, DEFAULT_STYLE_LINE, SIGN_STYLE_LINE } from "./fixtures/ass"; + +const EN = { name: "English", code: "en" }; +const SL = { name: "Slovenian", code: "sl" }; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +/** + * Mock the Ollama chat endpoint: "translates" by uppercasing each line and + * records every line the model was asked to translate into `sent`. + */ +function mockTranslate(sent: string[]) { + globalThis.fetch = (async (_url: string, init: any) => { + const prompt: string = JSON.parse(init.body).messages[0].content; + const block = prompt.split("Slovenian:\n\n\n")[1]!; + const lines = block.split("\n"); + sent.push(...lines); + const content = lines.map((l) => l.toUpperCase()).join("\n"); + return new Response(JSON.stringify({ message: { content } }), { status: 200 }); + }) as unknown as typeof fetch; +} + +function opts(over: Partial = {}): TranslateContentOptions { + return { + format: "ass", + batchSize: 100, + translateSignsSongs: true, + strategy: "translategemma", + isDialogueStyle: (s) => s === "Default", + ollama: { url: "http://localhost:11434", model: "translategemma:12b", source: EN, target: SL } as any, + ...over, + }; +} + +const STYLES = [DEFAULT_STYLE_LINE, SIGN_STYLE_LINE]; + +/** Replace the character at index `i`. */ +function sub(s: string, i: number, ch: string): string { + return s.slice(0, i) + ch + s.slice(i + 1); +} + +/** Format centiseconds as an ASS timecode (H:MM:SS.CC). */ +function cs(t: number): string { + const c = t % 100; + const s = Math.floor(t / 100) % 60; + const m = Math.floor(t / 6000) % 60; + const h = Math.floor(t / 360000); + return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(c).padStart(2, "0")}`; +} + +const GROUP = "General Title\u0000"; + +function sev(lineNo: number, startMs: number, endMs: number, visible: string, group = GROUP): SignEventInput { + return { lineNo, startMs, endMs, visible, group }; +} + +// The real typewriter sign from a production release (mid-text \alpha reveal; +// splitAssText strips the mid tag, so the model-facing text is the full +// sentence with one scrambled letter per frame). +const BASE = "A valuable herb that only grows \\Nin areas rich in magicules,\\Nused in healing potions."; + +describe("scrambleDistance", () => { + const S = "A valuable herb"; + + it("counts letter-for-letter substitutions", () => { + expect(scrambleDistance(S, S, 2)).toBe(0); + expect(scrambleDistance(sub(S, 2, "j"), S, 2)).toBe(1); // "A jaluable herb" + expect(scrambleDistance(sub(sub(S, 2, "j"), 4, "x"), S, 2)).toBe(2); // "A jaxuable herb" + }); + + it("returns null beyond max (early exit, order-independent)", () => { + const three = sub(sub(sub(S, 2, "j"), 4, "x"), 6, "o"); // "A jaxuoble herb" + expect(scrambleDistance(three, S, 2)).toBeNull(); + expect(scrambleDistance(sub(sub(S, 2, "j"), 4, "x"), S, 1)).toBeNull(); + }); + + it("returns null for length differences — insertions are content changes", () => { + // The stray-brace variant from the wild: "Ability }Established". + expect(scrambleDistance("Ability Established", "Ability }Established", 2)).toBeNull(); + }); + + it("returns null when a digit or punctuation position differs", () => { + expect(scrambleDistance("Floor 1", "Floor 2", 2)).toBeNull(); + expect(scrambleDistance("A valuable herb.", "A valuable herb,", 2)).toBeNull(); // punct vs punct + expect(scrambleDistance("A valuable herbs", "A valuable herb.", 2)).toBeNull(); // letter vs punct + }); + + it("fails closed for non-ASCII letters", () => { + expect(scrambleDistance("café", "cafe", 2)).toBeNull(); + }); + + it("treats case flips as substitutions", () => { + expect(scrambleDistance("Herb", "herb", 2)).toBe(1); + }); + + it("requires \\N break positions to align exactly", () => { + expect(scrambleDistance("up\\Ndown", "up\\Ndawn", 2)).toBe(1); // letter sub after the break + expect(scrambleDistance("u\\Np", "uN\\p", 2)).toBeNull(); // backslash vs letter + }); +}); + +describe("clusterSignEvents", () => { + it("merges a typewriter run (incl. a duplicated frame) with its hold; the hold wins as representative", () => { + const evs = [ + sev(1, 0, 40, sub(BASE, 0, "D")), // "D valuable herb..." + sev(2, 50, 90, sub(BASE, 2, "j")), // "A jaluable herb..." + sev(3, 90, 130, sub(BASE, 2, "j")), // held frame — exact duplicate + sev(4, 140, 180, sub(BASE, 4, "h")), // "A vahuable herb..." + sev(5, 200, 3200, BASE), // the 3s hold with the correct text + ]; + const clusters = clusterSignEvents(evs); + expect(clusters.length).toBe(1); + expect(clusters[0]!.representative.lineNo).toBe(5); + expect(clusters[0]!.representative.visible).toBe(BASE); + expect(clusters[0]!.members.length).toBe(5); + }); + + it("breaks ties for the representative toward the latest event (scrambles converge on correct text)", () => { + const clusters = clusterSignEvents([sev(1, 0, 1000, sub(BASE, 2, "j")), sev(2, 1100, 2100, BASE)]); + expect(clusters.length).toBe(1); + expect(clusters[0]!.representative.lineNo).toBe(2); + }); + + it("merges exact repeats regardless of time distance (preserves old dedup semantics)", () => { + const clusters = clusterSignEvents([sev(1, 0, 3000, BASE), sev(2, 600_000, 603_000, BASE)]); + expect(clusters.length).toBe(1); + }); + + it("only fuzzy-merges within windowMs", () => { + const clusters = clusterSignEvents([sev(1, 0, 3000, BASE), sev(2, 10_000, 13_000, sub(BASE, 2, "j"))]); + expect(clusters.length).toBe(2); // 7s gap > 5s window + }); + + it("never merges across digit differences", () => { + const clusters = clusterSignEvents([sev(1, 0, 1000, "Floor 1"), sev(2, 1100, 2100, "Floor 2")]); + expect(clusters.length).toBe(2); + }); + + it("never merges across groups (style+name)", () => { + const clusters = clusterSignEvents([sev(1, 0, 1000, BASE, "Signs\u0000A"), sev(2, 1100, 2100, BASE, "Signs\u0000B")]); + expect(clusters.length).toBe(2); + }); + + it("keeps unrelated sign texts apart", () => { + const clusters = clusterSignEvents([sev(1, 0, 1000, "Magistone Ore"), sev(2, 1100, 2100, "Unique Skill\\NPredator")]); + expect(clusters.length).toBe(2); + }); +}); + +describe("translateAss scramble clustering (integration)", () => { + it("translates a typewriter sign once from its hold; every frame inherits the translation with its own lead", async () => { + const frames = [sub(BASE, 0, "D"), sub(BASE, 2, "j"), sub(BASE, 2, "j"), sub(BASE, 4, "h")]; + const frameLines = frames.map((t, i) => `Dialogue: 1,${cs(1000 + i * 4)},${cs(1000 + (i + 1) * 4)},Signs,,0,0,0,,{\\pos(${i},${i})}${t}`); + const ass = buildAss({ + styles: STYLES, + events: [ + "Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello there", + ...frameLines, + `Dialogue: 1,${cs(1016)},${cs(1316)},Signs,,0,0,0,,{\\pos(9,9)}${BASE}`, // 3s hold + ], + }); + + const sent: string[] = []; + mockTranslate(sent); + + const out = await translateSubtitleContent(ass, opts()); + + // The model saw the CORRECT sentence exactly once — never a typo variant. + expect(sent.sort()).toEqual([BASE, "Hello there"]); + + // Every frame carries the shared translation with its own lead. + const T = BASE.toUpperCase(); + for (let i = 0; i < frames.length; i++) { + expect(out).toContain(`{\\pos(${i},${i})}${T}`); + } + expect(out).toContain(`{\\pos(9,9)}${T}`); + expect(out).toContain(",Default,,0,0,0,,HELLO THERE"); + + // No scrambled leftovers in either language. + expect(out).not.toContain("jaluable"); + expect(out).not.toContain("JALUABLE"); + expect(out).not.toContain("D valuable"); + }); + + it("clustering does not shadow churn: hold-less kana flicker is still skipped verbatim", async () => { + const kana = ["あかさたなはまやらわ", "いきしちにひみりゐん", "うくすつぬふむゆるぼ", "えけせてねへめれゑぞ", "おこそとのほもよろを"]; + const flicker = kana.map((t, i) => `Dialogue: 1,${cs(1000 + i * 4)},${cs(1000 + (i + 1) * 4)},Signs,Grain,0,0,0,,{\\pos(675,259)}${t}`); + const ass = buildAss({ + styles: STYLES, + events: ["Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello there", ...flicker], + }); + + const sent: string[] = []; + mockTranslate(sent); + + const out = await translateSubtitleContent(ass, opts()); + + expect(sent).toEqual(["Hello there"]); + for (const k of kana) { + expect(out).toContain(`{\\pos(675,259)}${k}`); + } + }); + + it("a short but legit lone sign (below READABLE_MS, no churn run) is still translated", async () => { + const ass = buildAss({ + styles: STYLES, + events: [ + "Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello there", + "Dialogue: 1,0:00:05.00,0:00:05.30,Signs,,0,0,0,,{\\pos(5,5)}Magistone Ore", // 300ms stamp + ], + }); + + const sent: string[] = []; + mockTranslate(sent); + + const out = await translateSubtitleContent(ass, opts()); + + expect(sent.sort()).toEqual(["Hello there", "Magistone Ore"]); + expect(out).toContain("{\\pos(5,5)}MAGISTONE ORE"); + }); +}); From fbd3bac4535ab1b30fa2188d6447373ebc2ca2e8 Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Tue, 7 Jul 2026 14:12:33 +0200 Subject: [PATCH 09/13] Improve translation of signs & songs --- src/letter-signs.ts | 515 +++++++++++++++++++++++++++ src/subtitle-translate.ts | 22 +- tests/fixtures/letter-sign-sample.ts | 61 ++++ tests/letter-signs.test.ts | 283 +++++++++++++++ 4 files changed, 880 insertions(+), 1 deletion(-) create mode 100644 src/letter-signs.ts create mode 100644 tests/fixtures/letter-sign-sample.ts create mode 100644 tests/letter-signs.test.ts diff --git a/src/letter-signs.ts b/src/letter-signs.ts new file mode 100644 index 0000000..b651987 --- /dev/null +++ b/src/letter-signs.ts @@ -0,0 +1,515 @@ +import { splitAssText, type AssEventLine } from "./ass-edit"; + +export const LETTER_SIGN_TUNING = { + /** Minimum deduped glyphs in a group before reconstruction is attempted. */ + minGlyphs: 3, + /** Glyphs link into one component when closer than this x median NN distance. */ + linkFactor: 2.5, + /** Chain aborts when (2nd-nearest / nearest) unvisited candidate < this. */ + ambiguityRatio: 1.3, + /** A chain step > this x median step inserts a word space. */ + spaceFactor: 1.9, + /** A chain step > this x median NN distance aborts (mis-merged component). */ + maxStepFactor: 3.25, + /** Components whose mean y differs <= this x median NN share a baseline (join with " "). */ + sameLineFactor: 1.5, + /** Max |delta| of \fscx / \fscy between glyphs of one sign. */ + scaleTolerance: 1.0, +}; + +export interface ReconstructedLetterSign { + /** Reading-order text; words joined with " ", distinct baselines with "\N". */ + text: string; + startMs: number; + endMs: number; + style: string; + name: string; + /** Every event line consumed: all glyphs, all layer copies, this frame-group. */ + memberLineNos: number[]; + /** + * Event that should carry the translated text (first glyph in reading + * order, crisp/non-\alpha layer copy). All other members get blanked. + * Equals lines[0].representativeLineNo. + */ + representativeLineNo: number; + /** + * Synthesized override block for a whole-sign collapse (first baseline's + * lead). Prefer letterSignReplacementTexts, which places each translated + * \N segment at its own baseline. Equals lines[0].replacementLead. + */ + replacementLead: string; + /** + * One entry per detected baseline, top to bottom - the segments the + * reconstructed text's "\N" breaks separate. Ring/arc typesetting keeps + * its geometry this way: each baseline anchors at ITS OWN bbox center + * (e.g. top of the ring and bottom of the ring), never at the whole + * sign's center, which for a ring is the (occupied) middle. + */ + lines: LetterSignLine[]; + /** Deduped glyph count (diagnostics/logging). */ + glyphCount: number; +} + +export interface LetterSignLine { + /** This baseline's reconstructed text (words joined with " "). */ + text: string; + /** Crisp copy of this baseline's first reading-order glyph. */ + representativeLineNo: number; + /** {\an5\pos(thisBaselineCenter)\frz(thisBaselineMean)...} */ + replacementLead: string; + /** Deduped glyphs on this baseline (fallback picks the roomiest line). */ + glyphCount: number; +} + +export interface LetterSignResult { + signs: ReconstructedLetterSign[]; + /** lineNos of every event consumed by a successful reconstruction. */ + consumed: Set; +} + +const POS_RE = /\\pos\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/; +const FRZ_RE = /\\frz(-?\d+(?:\.\d+)?)/; +// \fr with no axis letter is an alias for \frz. +const FR_RE = /\\fr(?![xyz])(-?\d+(?:\.\d+)?)/; +const FSCX_RE = /\\fscx(\d+(?:\.\d+)?)/; +const FSCY_RE = /\\fscy(\d+(?:\.\d+)?)/; +// \c / \1c primary colour; anchored on &H so \clip(...) can never match. +const COLOUR_RE = /\\1?c(&H[0-9A-Fa-f]+&)/; +const ALPHA_RE = /\\(?:alpha|1a)&H/; +const BLUR_RE = /\\blur(\d+(?:\.\d+)?)/; +const FN_RE = /\\fn([^\\}]+)/; +// Per-glyph animation: karaoke-FX / motion typesetting, never a static sign. +const ANIM_RE = /\\(?:t\(|move\()/; + +/** True when the visible text is exactly one character (code point). */ +function isSingleChar(visible: string): boolean { + const t = visible.trim(); + return [...t].length === 1; +} + +/** Layer is the first field after "Dialogue:" in the preserved prefix. */ +function parseLayer(prefix: string): number { + const m = prefix.match(/^[^:]*:\s*(-?\d+)\s*,/); + return m ? parseInt(m[1]!, 10) : 0; +} + +/** + * Effect is the last field of the preserved prefix (the standard event format + * puts it right before Text). Karaoke templates stamp it ("fx", "template", + * "code", "karaoke"); anything non-empty means the event belongs to an effect + * generator, not to static typesetting. + */ +function parseEffect(prefix: string): string { + const fields = prefix.replace(/,$/, "").split(","); + return (fields[fields.length - 1] ?? "").trim(); +} + +interface RawGlyph { + lineNo: number; + layer: number; + ch: string; + x: number; + y: number; + frz: number; + fscx: number | null; + fscy: number | null; + colour: string | null; + fn: string | null; + hasAlpha: boolean; + lead: string; +} + +/** One visual glyph after collapsing its layer copies (glow + crisp). */ +interface Glyph { + ch: string; + x: number; + y: number; + frz: number; + fscx: number | null; + fscy: number | null; + colour: string | null; + fn: string | null; + lineNos: number[]; + /** The preferred (crisp) copy carries the translation on collapse. */ + canonicalLineNo: number; + canonicalLead: string; +} + +const dist = (a: Glyph, b: Glyph): number => Math.hypot(a.x - b.x, a.y - b.y); + +const median = (xs: number[]): number => { + const s = [...xs].sort((a, b) => a - b); + return s[Math.floor(s.length / 2)]!; +}; + +/** Format a number for a synthesized tag: <=3 decimals, no trailing zeros. */ +const tidy = (n: number): string => String(parseFloat(n.toFixed(3))); + +/** Single-linkage components: glyphs closer than `maxLink` connect. */ +function connectedComponents(glyphs: Glyph[], maxLink: number): Glyph[][] { + const parent = glyphs.map((_, i) => i); + const find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i]!))); + for (let i = 0; i < glyphs.length; i++) { + for (let j = i + 1; j < glyphs.length; j++) { + if (dist(glyphs[i]!, glyphs[j]!) <= maxLink) { + parent[find(i)] = find(j); + } + } + } + const byRoot = new Map(); + for (let i = 0; i < glyphs.length; i++) { + const r = find(i); + let arr = byRoot.get(r); + if (!arr) byRoot.set(r, (arr = [])); + arr.push(glyphs[i]!); + } + return [...byRoot.values()]; +} + +/** + * Order one component's glyphs into reading order via greedy nearest-neighbor + * chaining from the leftmost glyph, and insert word spaces at outlier steps. + * Returns null when the order is ambiguous or a step is implausibly large. + */ +function chainComponent(comp: Glyph[], medianNN: number, t = LETTER_SIGN_TUNING): { glyphs: Glyph[]; text: string } | null { + if (comp.length === 1) return { glyphs: comp, text: comp[0]!.ch }; + + // Left-to-right assumption: start at the leftmost glyph (tie: topmost). + let start = comp[0]!; + for (const g of comp) { + if (g.x < start.x || (g.x === start.x && g.y < start.y)) start = g; + } + + const ordered: Glyph[] = [start]; + const visited = new Set([start]); + const steps: number[] = []; + + while (ordered.length < comp.length) { + const cur = ordered[ordered.length - 1]!; + let best: Glyph | null = null; + let d1 = Infinity; + let d2 = Infinity; + for (const g of comp) { + if (visited.has(g)) continue; + const d = dist(cur, g); + if (d < d1) { + d2 = d1; + d1 = d; + best = g; + } else if (d < d2) { + d2 = d; + } + } + // Two equally plausible next glyphs => the layout isn't a line of text. + if (d2 < t.ambiguityRatio * d1) return null; + // A step this large means the chain jumped across the layout (e.g. a + // curve that doubles back, or a bad merge). Fail closed. + if (d1 > t.maxStepFactor * medianNN * Math.max(1, t.spaceFactor)) return null; + ordered.push(best!); + visited.add(best!); + steps.push(d1); + } + + // Word spaces: steps clearly larger than the typical letter advance. + // Needs >=3 steps for a meaningful median; short chains get no spaces. + let text = ordered[0]!.ch; + const medStep = steps.length >= 3 ? median(steps) : Infinity; + for (let i = 0; i < steps.length; i++) { + if (steps[i]! > t.spaceFactor * medStep) text += " "; + text += ordered[i + 1]!.ch; + } + return { glyphs: ordered, text }; +} + +function reconstructGroup( + raws: RawGlyph[], + groupBad: boolean, + meta: { startMs: number; endMs: number; style: string; name: string }, + t = LETTER_SIGN_TUNING, +): ReconstructedLetterSign | null { + if (groupBad) return null; + + // Collapse layer copies: events drawing the same character at the same + // position are one visual glyph (glow pass + crisp pass). The copy without + // \alpha (or the highest layer) is canonical - it carries the translation. + const stacks = new Map(); + for (const r of raws) { + const key = `${r.ch}\u0000${r.x}\u0000${r.y}`; + let arr = stacks.get(key); + if (!arr) stacks.set(key, (arr = [])); + arr.push(r); + } + + const glyphs: Glyph[] = []; + for (const stack of stacks.values()) { + let canon = stack[0]!; + for (const r of stack) { + if (r.hasAlpha !== canon.hasAlpha) { + if (!r.hasAlpha) canon = r; + } else if (r.layer > canon.layer) { + canon = r; + } + } + glyphs.push({ + ch: canon.ch, + x: canon.x, + y: canon.y, + frz: canon.frz, + fscx: canon.fscx, + fscy: canon.fscy, + colour: canon.colour, + fn: canon.fn, + lineNos: stack.map((r) => r.lineNo), + canonicalLineNo: canon.lineNo, + canonicalLead: canon.lead, + }); + } + + if (glyphs.length < t.minGlyphs) return null; + + // A "word" whose glyphs are all one repeated character is a particle + // effect (grain/dust/rain dingbat fonts draw specks as a letter), never + // text. Collapsing it would render the literal letters on screen. + if (new Set(glyphs.map((g) => g.ch)).size === 1) return null; + + // Tag coherence: glyphs of one sign share scale, colour, and font. + // Disagreement means unrelated single-char signs that merely share timing. + const nums = (sel: (g: Glyph) => number | null): boolean => { + const vals = glyphs.map(sel); + if (vals.some((v) => v === null) !== vals.every((v) => v === null)) return false; // mixed tagged/untagged + const nn = vals.filter((v): v is number => v !== null); + return nn.length === 0 || Math.max(...nn) - Math.min(...nn) <= t.scaleTolerance; + }; + if (!nums((g) => g.fscx) || !nums((g) => g.fscy)) return null; + if (new Set(glyphs.map((g) => g.colour ?? "")).size > 1) return null; + if (new Set(glyphs.map((g) => g.fn ?? "")).size > 1) return null; + + // Spatial structure: nearest-neighbor scale, then components, then chains. + const nn: number[] = []; + for (const g of glyphs) { + let m = Infinity; + for (const h of glyphs) { + if (h !== g) m = Math.min(m, dist(g, h)); + } + nn.push(m); + } + const medianNN = median(nn); + if (!(medianNN > 0)) return null; // overlapping glyphs - not a text line + + const comps = connectedComponents(glyphs, t.linkFactor * medianNN); + + interface Chained { + text: string; + glyphs: Glyph[]; + meanX: number; + meanY: number; + } + const chained: Chained[] = []; + for (const comp of comps) { + const c = chainComponent(comp, medianNN, t); + if (c === null) return null; // one ambiguous component poisons the group + chained.push({ + text: c.text, + glyphs: c.glyphs, + meanX: comp.reduce((s, g) => s + g.x, 0) / comp.length, + meanY: comp.reduce((s, g) => s + g.y, 0) / comp.length, + }); + } + + // Assemble components: top-to-bottom, left-to-right. Components on the + // same baseline are words (join " "); distinct baselines are lines ("\N"). + chained.sort((a, b) => a.meanY - b.meanY || a.meanX - b.meanX); + const lines: Chained[][] = []; + for (const c of chained) { + const cur = lines[lines.length - 1]; + if (cur && Math.abs(c.meanY - cur[0]!.meanY) <= t.sameLineFactor * medianNN) cur.push(c); + else lines.push([c]); + } + const text = lines + .map((line) => { + line.sort((a, b) => a.meanX - b.meanX); + return line.map((c) => c.text).join(" "); + }) + .join("\\N"); + + // A sign must actually say something: at least two letters. + if ((text.match(/\p{L}/gu)?.length ?? 0) < 2) return null; + + // One anchor PER BASELINE, not per sign: text arced around a circle or + // split across the screen must land back where its letters were, never at + // the whole-sign bbox center (for a ring that's the occupied middle). + // Each baseline gets its own bbox center, mean rotation, and the tag + // profile of its first glyph's crisp copy. \an5 makes \pos the true + // center regardless of the style's default alignment. + const buildLead = (lineGlyphs: Glyph[]): string => { + let minX = Infinity, + maxX = -Infinity, + minY = Infinity, + maxY = -Infinity, + frzSum = 0; + for (const g of lineGlyphs) { + minX = Math.min(minX, g.x); + maxX = Math.max(maxX, g.x); + minY = Math.min(minY, g.y); + maxY = Math.max(maxY, g.y); + frzSum += g.frz; + } + const rep = lineGlyphs[0]!; + const meanFrz = frzSum / lineGlyphs.length; + + let lead = `{\\an5\\pos(${tidy((minX + maxX) / 2)},${tidy((minY + maxY) / 2)})`; + if (Math.abs(meanFrz) >= 0.001) lead += `\\frz${tidy(meanFrz)}`; + if (rep.fscx !== null) lead += `\\fscx${tidy(rep.fscx)}`; + if (rep.fscy !== null) lead += `\\fscy${tidy(rep.fscy)}`; + if (rep.fn !== null) lead += `\\fn${rep.fn}`; + if (rep.colour !== null) lead += `\\c${rep.colour}`; + // The canonical copy is chosen to avoid \alpha (glow-pass marker), but + // a sign whose every copy is translucent keeps its transparency. + const alpha = rep.canonicalLead.match(/\\(?:alpha|1a)(&H[0-9A-Fa-f]+&?)/); + if (alpha) lead += `\\alpha${alpha[1]}`; + const blur = rep.canonicalLead.match(BLUR_RE); + if (blur) lead += `\\blur${blur[1]}`; + return lead + "}"; + }; + + const signLines: LetterSignLine[] = lines.map((line) => { + const lineGlyphs = line.flatMap((c) => c.glyphs); // reading order + return { + text: line.map((c) => c.text).join(" "), + representativeLineNo: lineGlyphs[0]!.canonicalLineNo, + replacementLead: buildLead(lineGlyphs), + glyphCount: lineGlyphs.length, + }; + }); + + const memberLineNos = glyphs.flatMap((g) => g.lineNos).sort((a, b) => a - b); + + return { + text, + startMs: meta.startMs, + endMs: meta.endMs, + style: meta.style, + name: meta.name, + memberLineNos, + representativeLineNo: signLines[0]!.representativeLineNo, + replacementLead: signLines[0]!.replacementLead, + lines: signLines, + glyphCount: glyphs.length, + }; +} + +/** + * Detect per-letter typeset signs among non-dialogue events and reconstruct + * their text. Events of successful groups are reported in `consumed` so the + * caller can skip them in its normal per-event loop; each sign should then be + * fed to translation as ONE unit keyed by `representativeLineNo`. + * + * Failed groups consume nothing - their events flow through the existing + * pipeline untouched (and land in the isSingleCharSign verbatim skip). + */ +export function reconstructLetterSigns(events: AssEventLine[], isDialogue: (style: string) => boolean, tuning = LETTER_SIGN_TUNING): LetterSignResult { + interface Group { + raws: RawGlyph[]; + bad: boolean; + meta: { startMs: number; endMs: number; style: string; name: string }; + } + const groups = new Map(); + + for (const ev of events) { + if (isDialogue(ev.style)) continue; + // Karaoke-template output ("fx"/"template"/"code"/...) belongs to an + // effect generator; its glyphs are never static-typeset letters. + if (parseEffect(ev.prefix) !== "") continue; + const parts = splitAssText(ev.rawText); + if (!parts.translatable) continue; // drawings/tag-only never join or block a group + + const key = `${ev.startMs}\u0000${ev.endMs}\u0000${ev.style}\u0000${ev.name}`; + let g = groups.get(key); + if (!g) { + groups.set(key, (g = { raws: [], bad: false, meta: { startMs: ev.startMs, endMs: ev.endMs, style: ev.style, name: ev.name } })); + } + + // A multi-char sibling means the sign is NOT purely per-letter; a glyph + // we can't place means the word would come out with letters missing. + // Either way the whole group must stay verbatim. + if (!isSingleChar(parts.visible)) { + g.bad = true; + continue; + } + const pos = parts.lead.match(POS_RE); + if (!pos) { + g.bad = true; + continue; + } + // Animated glyphs (\t transforms, \move) are karaoke-FX or motion + // typesetting: the anchor doesn't describe a static reading line, and + // collapsing would freeze the effect. Poison the whole group. + if (ANIM_RE.test(parts.lead)) { + g.bad = true; + continue; + } + + const frz = parts.lead.match(FRZ_RE) ?? parts.lead.match(FR_RE); + g.raws.push({ + lineNo: ev.lineNo, + layer: parseLayer(ev.prefix), + ch: parts.visible.trim(), + x: parseFloat(pos[1]!), + y: parseFloat(pos[2]!), + frz: frz ? parseFloat(frz[1]!) : 0, + fscx: parts.lead.match(FSCX_RE) ? parseFloat(parts.lead.match(FSCX_RE)![1]!) : null, + fscy: parts.lead.match(FSCY_RE) ? parseFloat(parts.lead.match(FSCY_RE)![1]!) : null, + colour: parts.lead.match(COLOUR_RE)?.[1] ?? null, + fn: parts.lead.match(FN_RE)?.[1]?.trim() ?? null, + hasAlpha: ALPHA_RE.test(parts.lead), + lead: parts.lead, + }); + } + + const signs: ReconstructedLetterSign[] = []; + const consumed = new Set(); + for (const g of groups.values()) { + const sign = reconstructGroup(g.raws, g.bad, g.meta, tuning); + if (!sign) continue; + signs.push(sign); + for (const n of sign.memberLineNos) consumed.add(n); + } + signs.sort((a, b) => a.startMs - b.startMs || a.representativeLineNo - b.representativeLineNo); + return { signs, consumed }; +} + +/** + * Tier-1 collapse rendering: the Text replacements for one reconstructed sign + * once its translation is known. + * + * The translation's "\N" segments map 1:1 onto the sign's baselines: segment i + * renders at baseline i's own anchor, so ring/arc typesetting keeps its + * geometry ("Pain" over the top of the ring, "nullification" under the + * bottom - and their translations land in the same two places, never in the + * ring's occupied middle). Every non-carrying member event is blanked (an + * empty Text field renders nothing, so timing/structure stay untouched). + * + * When the translation's break count doesn't match the baseline count (the + * model merged or added "\N"), the whole translated string falls back to the + * baseline with the most glyphs - the one with the most room - rather than a + * synthetic center. + */ +export function letterSignReplacementTexts(sign: ReconstructedLetterSign, translated: string): Map { + const out = new Map(); + for (const lineNo of sign.memberLineNos) out.set(lineNo, ""); + + const segments = translated.split(/\\N/i).map((s) => s.trim()); + if (segments.length === sign.lines.length && segments.every((s) => s !== "")) { + for (let i = 0; i < sign.lines.length; i++) { + const line = sign.lines[i]!; + out.set(line.representativeLineNo, line.replacementLead + segments[i]!); + } + } else { + let roomiest = sign.lines[0]!; + for (const line of sign.lines) { + if (line.glyphCount > roomiest.glyphCount) roomiest = line; + } + out.set(roomiest.representativeLineNo, roomiest.replacementLead + translated); + } + return out; +} diff --git a/src/subtitle-translate.ts b/src/subtitle-translate.ts index 9682627..b9d788d 100644 --- a/src/subtitle-translate.ts +++ b/src/subtitle-translate.ts @@ -5,6 +5,7 @@ import { translateBatch } from "./ollama"; import { resolveTranslateLang, normalizeTag, type TranslateLang } from "./translate-languages"; import { translateBatchGeneric, type GenericOptions, type TranslateItem } from "./ollama-generic"; import { createSemaphore, type Semaphore } from "./concurrency"; +import { letterSignReplacementTexts, reconstructLetterSigns } from "./letter-signs"; /** * True when a sign/song's visible text is a single character. Animated @@ -341,7 +342,19 @@ async function translateAss(content: string, opts: TranslateContentOptions): Pro const signInputs: SignEventInput[] = []; let skippedSigns = 0; + const { signs: letterSigns, consumed: letterConsumed } = reconstructLetterSigns(events, isDialogue); + const letterSignByRep = new Map(letterSigns.map((s) => [s.representativeLineNo, s])); + for (const ev of events) { + if (letterConsumed.has(ev.lineNo)) { + const sign = letterSignByRep.get(ev.lineNo); + if (!sign) continue; + leadByKey.set(ev.lineNo, sign.replacementLead); + evByLineNo.set(ev.lineNo, ev); + signInputs.push({ lineNo: ev.lineNo, startMs: sign.startMs, endMs: sign.endMs, visible: sign.text, group: `${ev.style}\u0000${ev.name}\u0000letters` }); + continue; + } + const dialogue = isDialogue(ev.style); if (dialogueOnly && !dialogue) continue; const parts = splitAssText(ev.rawText); @@ -418,7 +431,14 @@ async function translateAss(content: string, opts: TranslateContentOptions): Pro const t = translatedVisible.get(c.representative.lineNo); if (t === undefined) continue; for (const m of c.members) { - newTextByLineNo.set(m.lineNo, joinAssText(leadByKey.get(m.lineNo) ?? "", t)); + const sign = letterSignByRep.get(m.lineNo); + if (sign) { + for (const [lineNo, text] of letterSignReplacementTexts(sign, t)) { + newTextByLineNo.set(lineNo, text); + } + } else { + newTextByLineNo.set(m.lineNo, joinAssText(leadByKey.get(m.lineNo) ?? "", t)); + } } } diff --git a/tests/fixtures/letter-sign-sample.ts b/tests/fixtures/letter-sign-sample.ts new file mode 100644 index 0000000..50a0fbd --- /dev/null +++ b/tests/fixtures/letter-sign-sample.ts @@ -0,0 +1,61 @@ +/** + * The production sample this feature was designed against: the words + * "Pain nullification" typeset one event per character along a curve, style + * "Hurts", actor "Text", each glyph duplicated on two layers (layer 0 = glow + * pass with \alpha&H80&\blur1, layer 1 = crisp pass with \blur0.6) and the + * whole set re-emitted for a second frame window. + * + * Glyph anchors and rotations are the exact values from the source file; the + * lines are regenerated from this table so the fixture stays reviewable. + */ + +export interface SampleGlyph { + ch: string; + x: number; + y: number; + frz: number; + /** Aegisub extradata reference emitted before the override block ({=NNN}). */ + extra: number; +} + +export const SAMPLE_GLYPHS: SampleGlyph[] = [ + // "Pain" - upper baseline + { ch: "P", x: 917.232, y: 295.339, frz: 10.125, extra: 119 }, + { ch: "a", x: 947.527, y: 292.6, frz: 1.332, extra: 120 }, + { ch: "i", x: 969.502, y: 292.089, frz: 1.332, extra: 120 }, + { ch: "n", x: 991.728, y: 294.787, frz: -8.616, extra: 120 }, + // "nullification" - lower curved baseline + { ch: "n", x: 828.158, y: 753.079, frz: -26.565, extra: 125 }, + { ch: "u", x: 857.096, y: 765.898, frz: -23.199, extra: 126 }, + { ch: "l", x: 877.713, y: 772.49, frz: -15.945, extra: 126 }, + { ch: "l", x: 888.96, y: 775.703, frz: -15.945, extra: 126 }, + { ch: "i", x: 901.073, y: 778.605, frz: -13.241, extra: 126 }, + { ch: "f", x: 916.875, y: 782.323, frz: -13.241, extra: 126 }, + { ch: "i", x: 932.769, y: 785.594, frz: -10.305, extra: 126 }, + { ch: "c", x: 952.599, y: 787.61, frz: -3.576, extra: 126 }, + { ch: "a", x: 980.938, y: 786.406, frz: 10.62, extra: 126 }, + { ch: "t", x: 1006.223, y: 784.708, frz: 10.62, extra: 126 }, + { ch: "i", x: 1022.33, y: 780.106, frz: 17.526, extra: 126 }, + { ch: "o", x: 1042.826, y: 773.634, frz: 17.526, extra: 126 }, + { ch: "n", x: 1071.194, y: 761.922, frz: 23.199, extra: 126 }, +]; + +export const SAMPLE_FRAME_WINDOWS: Array<[string, string]> = [ + ["0:12:52.45", "0:12:52.50"], + ["0:12:52.54", "0:12:52.58"], +]; + +/** All 68 Dialogue lines of the sample, byte-identical to the source file. */ +export function sampleEvents(): string[] { + const lines: string[] = []; + for (const [start, end] of SAMPLE_FRAME_WINDOWS) { + for (const g of SAMPLE_GLYPHS) { + const base = `{=${g.extra}}{\\pos(${g.x},${g.y})\\fscx76\\fscy73\\frz${g.frz}\\c&H0E0913&`; + lines.push(`Dialogue: 0,${start},${end},Hurts,Text,0,0,0,,${base}\\alpha&H80&\\blur1}${g.ch}`); + lines.push(`Dialogue: 1,${start},${end},Hurts,Text,0,0,0,,${base}\\blur0.6}${g.ch}`); + } + } + return lines; +} + +export const HURTS_STYLE_LINE = "Style: Hurts,Arial,60,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,5,10,10,10,1"; diff --git a/tests/letter-signs.test.ts b/tests/letter-signs.test.ts new file mode 100644 index 0000000..c9c2117 --- /dev/null +++ b/tests/letter-signs.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from "bun:test"; +import { parseAssEvents } from "../src/ass-edit"; +import { reconstructLetterSigns, letterSignReplacementTexts, type ReconstructedLetterSign } from "../src/letter-signs"; +import { buildAss, DEFAULT_STYLE_LINE, SIGN_STYLE_LINE } from "./fixtures/ass"; +import { sampleEvents, SAMPLE_GLYPHS, HURTS_STYLE_LINE } from "./fixtures/letter-sign-sample"; + +const isDialogue = (s: string) => s === "Default"; + +/** Build an ASS doc, parse it, reconstruct, and return everything. */ +function run(events: string[], styles = [DEFAULT_STYLE_LINE, SIGN_STYLE_LINE, HURTS_STYLE_LINE]) { + const ass = buildAss({ styles, events }); + const parsed = parseAssEvents(ass); + return { ass, events: parsed.events, ...reconstructLetterSigns(parsed.events, isDialogue) }; +} + +/** Shorthand for a single-char sign event. */ +function letter(ch: string, x: number, y: number, over = "", timing = "0:00:01.00,0:00:02.00", style = "Signs", name = ""): string { + return `Dialogue: 0,${timing},${style},${name},0,0,0,,{\\pos(${x},${y})${over}}${ch}`; +} + +describe("reconstructLetterSigns on the production sample", () => { + it('reconstructs "Pain\\Nnullification" once per frame-group', () => { + const { signs, consumed, events } = run(sampleEvents()); + + expect(signs.length).toBe(2); + for (const s of signs) { + expect(s.text).toBe("Pain\\Nnullification"); + expect(s.glyphCount).toBe(17); + expect(s.memberLineNos.length).toBe(34); // 17 glyphs x 2 layers + expect(s.style).toBe("Hurts"); + expect(s.name).toBe("Text"); + } + + // Every one of the 68 sample events is consumed - nothing leaks through + // to the verbatim single-char skip. + expect(consumed.size).toBe(events.length); + for (const ev of events) expect(consumed.has(ev.lineNo)).toBe(true); + + // The two frame-groups keep their own timing (52.45-52.50, 52.54-52.58). + expect(signs[0]!.startMs).toBe(772_450); + expect(signs[0]!.endMs).toBe(772_500); + expect(signs[1]!.startMs).toBe(772_540); + expect(signs[1]!.endMs).toBe(772_580); + + // Identical text per frame-group is exactly what lets the existing + // clusterSignEvents exact-repeat merge translate this once. + expect(signs[0]!.text).toBe(signs[1]!.text); + }); + + it("elects the crisp (non-\\alpha) copy of the first reading-order glyph as representative", () => { + const { signs, events } = run(sampleEvents()); + const rep = events.find((e) => e.lineNo === signs[0]!.representativeLineNo)!; + + // Reading order is top line first, so the representative draws "P"... + expect(rep.rawText.endsWith("}P")).toBe(true); + // ...from the crisp layer-1 pass, not the glow pass. + expect(rep.prefix.startsWith("Dialogue: 1,")).toBe(true); + expect(rep.rawText).not.toContain("\\alpha"); + }); + + it("anchors each baseline at ITS OWN bbox center - never at the ring's occupied middle", () => { + const { signs } = run(sampleEvents()); + const sign = signs[0]!; + expect(sign.lines.length).toBe(2); + + const pain = SAMPLE_GLYPHS.slice(0, 4); + const nullif = SAMPLE_GLYPHS.slice(4); + const center = (gs: typeof SAMPLE_GLYPHS) => [ + (Math.min(...gs.map((g) => g.x)) + Math.max(...gs.map((g) => g.x))) / 2, + (Math.min(...gs.map((g) => g.y)) + Math.max(...gs.map((g) => g.y))) / 2, + ]; + const meanFrz = (gs: typeof SAMPLE_GLYPHS) => gs.reduce((s, g) => s + g.frz, 0) / gs.length; + + for (const [i, glyphs] of [pain, nullif].entries()) { + const lead = sign.lines[i]!.replacementLead; + expect(lead.startsWith("{\\an5\\pos(")).toBe(true); + const pos = lead.match(/\\pos\((-?[\d.]+),(-?[\d.]+)\)/)!; + const [cx, cy] = center(glyphs); + expect(parseFloat(pos[1]!)).toBeCloseTo(cx!, 2); + expect(parseFloat(pos[2]!)).toBeCloseTo(cy!, 2); + const frz = lead.match(/\\frz(-?[\d.]+)/)!; + expect(parseFloat(frz[1]!)).toBeCloseTo(meanFrz(glyphs), 2); + // Scale, colour, and the crisp pass's blur are inherited; the glow + // pass's \alpha is not. + expect(lead).toContain("\\fscx76"); + expect(lead).toContain("\\fscy73"); + expect(lead).toContain("\\c&H0E0913&"); + expect(lead).toContain("\\blur0.6"); + expect(lead).not.toContain("\\alpha"); + } + + // The two baseline anchors stay ~480px apart - the sign's own bbox + // center (the middle of the ring, where the kanji sits) is never used. + const y0 = parseFloat(sign.lines[0]!.replacementLead.match(/\\pos\(-?[\d.]+,(-?[\d.]+)\)/)![1]!); + const y1 = parseFloat(sign.lines[1]!.replacementLead.match(/\\pos\(-?[\d.]+,(-?[\d.]+)\)/)![1]!); + expect(y1 - y0).toBeGreaterThan(400); + expect(sign.lines[0]!.text).toBe("Pain"); + expect(sign.lines[1]!.text).toBe("nullification"); + }); + + it("letterSignReplacementTexts places each translated \\N segment at its own baseline and blanks the rest", () => { + const { signs, events } = run(sampleEvents()); + const sign = signs[0]!; + const texts = letterSignReplacementTexts(sign, "Izničenje\\Nbolečine"); + + expect(texts.size).toBe(34); + expect(texts.get(sign.lines[0]!.representativeLineNo)).toBe(sign.lines[0]!.replacementLead + "Izničenje"); + expect(texts.get(sign.lines[1]!.representativeLineNo)).toBe(sign.lines[1]!.replacementLead + "bolečine"); + const carriers = new Set([sign.lines[0]!.representativeLineNo, sign.lines[1]!.representativeLineNo]); + for (const [lineNo, text] of texts) { + if (!carriers.has(lineNo)) expect(text).toBe(""); + } + + // The lower segment's carrier is the crisp copy of the lower arc's + // first glyph ("n" of nullification), so it keeps that arc's timing row. + const lower = events.find((e) => e.lineNo === sign.lines[1]!.representativeLineNo)!; + expect(lower.rawText.endsWith("}n")).toBe(true); + expect(lower.rawText).not.toContain("\\alpha"); + }); + + it("letterSignReplacementTexts falls back to the roomiest baseline when \\N counts mismatch", () => { + const { signs } = run(sampleEvents()); + const sign = signs[0]!; + const texts = letterSignReplacementTexts(sign, "Izničenje bolečine"); // no \N: 1 segment, 2 baselines + + // "nullification" (13 glyphs) has more room than "Pain" (4). + const roomiest = sign.lines[1]!; + expect(texts.get(roomiest.representativeLineNo)).toBe(roomiest.replacementLead + "Izničenje bolečine"); + expect(texts.get(sign.lines[0]!.representativeLineNo)).toBe(""); + }); +}); + +describe("reading order, spaces, and lines (synthetic)", () => { + it("inserts a word space at a clear advance gap on one baseline", () => { + // "BIG SALE": normal advances 25-30px, the space gap is 60px. + const glyphs: Array<[string, number]> = [ + ["B", 100], + ["I", 130], + ["G", 155], + ["S", 215], + ["A", 245], + ["L", 275], + ["E", 300], + ]; + const { signs } = run(glyphs.map(([ch, x]) => letter(ch, x, 100))); + + expect(signs.length).toBe(1); + expect(signs[0]!.text).toBe("BIG SALE"); + }); + + it("joins far-apart words on one baseline with a space and distinct baselines with \\N", () => { + const { signs } = run([ + // "NO WAY" - same y, gap far beyond the component link radius + letter("N", 100, 100), + letter("O", 125, 100), + letter("W", 400, 100), + letter("A", 425, 100), + letter("Y", 450, 100), + // "OK" on a clearly separate baseline + letter("O", 100, 400), + letter("K", 125, 400), + ]); + + expect(signs.length).toBe(1); + expect(signs[0]!.text).toBe("NO WAY\\NOK"); + }); + + it("orders a right-leaning curved arc correctly (regression: frz variance is irrelevant to ordering)", () => { + const { signs } = run(sampleEvents().slice(0, 34)); // first frame-group only + expect(signs[0]!.text.split("\\N")[1]).toBe("nullification"); + }); +}); + +describe("fail-closed guards", () => { + it("aborts on ambiguous grid layouts (no letters consumed)", () => { + const { signs, consumed } = run([letter("A", 0, 0), letter("B", 30, 0), letter("C", 0, 30), letter("D", 30, 30)]); + expect(signs.length).toBe(0); + expect(consumed.size).toBe(0); + }); + + it("aborts below the minimum glyph count", () => { + const { signs, consumed } = run([letter("N", 100, 100), letter("O", 125, 100)]); + expect(signs.length).toBe(0); + expect(consumed.size).toBe(0); + }); + + it("aborts when glyphs disagree on scale (unrelated signs sharing timing)", () => { + const { signs } = run([letter("A", 100, 100, "\\fscx76"), letter("B", 125, 100, "\\fscx76"), letter("C", 150, 100, "\\fscx50")]); + expect(signs.length).toBe(0); + }); + + it("aborts when glyphs disagree on colour", () => { + const { signs } = run([letter("A", 100, 100, "\\c&H0000FF&"), letter("B", 125, 100, "\\c&H0000FF&"), letter("C", 150, 100, "\\c&H00FF00&")]); + expect(signs.length).toBe(0); + }); + + it("a multi-char sibling with the same timing/style/name poisons the whole group", () => { + const { signs, consumed } = run([letter("A", 100, 100), letter("B", 125, 100), letter("C", 150, 100), letter("OK", 300, 100)]); + expect(signs.length).toBe(0); + expect(consumed.size).toBe(0); + }); + + it("a glyph without \\pos poisons the whole group (a letter would go missing)", () => { + const { signs, consumed } = run([letter("A", 100, 100), letter("B", 125, 100), "Dialogue: 0,0:00:01.00,0:00:02.00,Signs,,0,0,0,,{\\frz10}C"]); + expect(signs.length).toBe(0); + expect(consumed.size).toBe(0); + }); + + it("requires at least two letters (punctuation clusters stay verbatim)", () => { + const { signs } = run([letter(".", 100, 100), letter(".", 125, 100), letter(".", 150, 100)]); + expect(signs.length).toBe(0); + }); + + it("never touches dialogue-styled events", () => { + const { signs, consumed } = run([ + letter("H", 100, 100, "", "0:00:01.00,0:00:02.00", "Default"), + letter("I", 125, 100, "", "0:00:01.00,0:00:02.00", "Default"), + letter("!", 150, 100, "", "0:00:01.00,0:00:02.00", "Default"), + ]); + expect(signs.length).toBe(0); + expect(consumed.size).toBe(0); + }); + + it("splits groups by name/actor - two typesets sharing timing don't merge", () => { + const a = ["W", "H", "Y"].map((ch, i) => letter(ch, 100 + i * 25, 100, "", "0:00:01.00,0:00:02.00", "Signs", "SignA")); + const b = ["N", "O", "W"].map((ch, i) => letter(ch, 100 + i * 25, 400, "", "0:00:01.00,0:00:02.00", "Signs", "SignB")); + const { signs } = run([...a, ...b]); + expect(signs.map((s) => s.text).sort()).toEqual(["NOW", "WHY"]); + }); + + it("regression: \\fnGrain particle effects (same char repeated) stay verbatim, not 'pppp'", () => { + // Real shape from a production episode: dust specks drawn as the letter + // "p" in a dingbat font, one event per speck. + const specks = [ + [451.533, 338.067], + [447.111, 310.222], + [495.111, 334.222], + [490.689, 306.377], + ].map(([x, y]) => `Dialogue: 2,0:03:31.64,0:03:31.73,Hurts,Grain,0,0,0,,{\\pos(${x},${y})\\fscx53\\fscy28\\fnGrain\\c&H230A04&\\alpha&H20&\\blur1.5}p`); + const { signs, consumed } = run(specks); + expect(signs.length).toBe(0); + expect(consumed.size).toBe(0); + }); + + it("regression: karaoke-template events (Effect=fx) are never candidates", () => { + const kfx = ["t", "s", "u"].map( + (ch, i) => `Dialogue: 0,0:22:33.57,0:22:33.72,OP-R2,,0,0,0,fx,{\\an5\\pos(${1200 + i * 30},75)\\bord0\\blur12\\t(0,60,\\alpha&HAF&\\blur0)}${ch}`, + ); + const { signs, consumed } = run(kfx); + expect(signs.length).toBe(0); + expect(consumed.size).toBe(0); + }); + + it("regression: a \\t-animated glyph poisons its group even without an Effect field", () => { + const { signs, consumed } = run([letter("A", 100, 100), letter("B", 125, 100), letter("C", 150, 100, "\\t(0,60,\\alpha&HAF&)")]); + expect(signs.length).toBe(0); + expect(consumed.size).toBe(0); + }); + + it("regression: glyphs disagreeing on \\fn abort; a shared inline \\fn is inherited by the collapse lead", () => { + const mixed = run([letter("A", 100, 100, "\\fnGandhi Sans"), letter("B", 125, 100, "\\fnGandhi Sans"), letter("C", 150, 100, "\\fnGrain")]); + expect(mixed.signs.length).toBe(0); + + const shared = run([letter("A", 100, 100, "\\fnGandhi Sans"), letter("B", 125, 100, "\\fnGandhi Sans"), letter("C", 150, 100, "\\fnGandhi Sans")]); + expect(shared.signs.length).toBe(1); + expect(shared.signs[0]!.replacementLead).toContain("\\fnGandhi Sans"); + }); + + it("regression: a uniformly translucent sign keeps its \\alpha in the collapse lead", () => { + const { signs } = run([letter("L", 100, 100, "\\alpha&H20&"), letter("O", 125, 100, "\\alpha&H20&"), letter("W", 150, 100, "\\alpha&H20&")]); + expect(signs.length).toBe(1); + expect(signs[0]!.replacementLead).toContain("\\alpha&H20&"); + }); + + it("drawings sharing the group's timing neither join nor poison it (backing boxes stay verbatim)", () => { + const box = "Dialogue: 0,0:00:01.00,0:00:02.00,Signs,,0,0,0,,{\\pos(100,100)\\p1}m 0 0 l 200 0 200 50 0 50{\\p0}"; + const { signs, consumed, events } = run([box, letter("A", 100, 100), letter("B", 125, 100), letter("C", 150, 100)]); + expect(signs.length).toBe(1); + expect(signs[0]!.text).toBe("ABC"); + const boxEv = events.find((e) => e.rawText.includes("\\p1"))!; + expect(consumed.has(boxEv.lineNo)).toBe(false); + }); +}); From 285a572ef8173dd6ecc2f212c64d6fb06942c560 Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Tue, 7 Jul 2026 18:32:57 +0200 Subject: [PATCH 10/13] Organize back-end code --- src/api/benchmark.ts | 24 + src/api/fonts.ts | 125 ++++ src/api/index.ts | 25 + src/api/jobs.ts | 204 ++++++ src/api/library.ts | 60 ++ src/api/previews.ts | 90 +++ src/api/queue.ts | 20 + src/api/settings.ts | 51 ++ src/api/system.ts | 22 + src/api/translate.ts | 59 ++ src/api/vs-presets.ts | 19 + src/{ => core}/concurrency.ts | 0 src/{ => core}/config.ts | 4 +- src/{ => core}/encoders.ts | 2 +- src/{ => core}/logger.ts | 0 src/{ => core}/mkv-tags.ts | 2 +- src/{ => core}/naming.ts | 0 src/{ => core}/process.ts | 0 src/{ => core}/system.ts | 0 src/{ => core}/types.ts | 0 src/{ => fonts}/font-instance.ts | 4 +- src/{ => fonts}/fonts.ts | 8 +- src/{ => fonts}/script-detect.ts | 0 src/{ => fonts}/system-fonts.ts | 4 +- src/index.ts | 665 +----------------- src/{ => pipeline}/encoder.ts | 40 +- src/{ => pipeline}/preview-encoder.ts | 21 +- src/{ => pipeline}/probe.ts | 4 +- src/{ => pipeline}/source-analysis.ts | 6 +- src/{ => queue}/library.ts | 2 +- src/{ => queue}/store.ts | 22 +- src/{ => queue}/watcher.ts | 4 +- src/{ => settings}/settings-code.ts | 6 +- src/{ => subtitles}/ass-classifier.ts | 0 src/{ => subtitles}/ass-edit.ts | 0 src/{ => subtitles}/ass-style.ts | 4 +- src/{ => subtitles}/letter-signs.ts | 0 src/{ => subtitles}/srt-edit.ts | 0 src/{ => subtitles}/subtitle-style.ts | 4 +- src/{ => tracks}/tracks.ts | 12 +- src/{ => translate}/deepseek.ts | 2 +- .../generic.ts} | 2 +- src/{ => translate}/ollama.ts | 2 +- src/{ => translate}/subtitle-translate.ts | 14 +- src/{ => translate}/translate-languages.ts | 2 +- src/{ => translate}/translate-provider.ts | 0 src/{ => translate}/translate-step.ts | 16 +- src/{ => video}/auto-denoise.ts | 6 +- src/{ => video}/benchmark.ts | 8 +- src/{ => video}/color-metadata.ts | 4 +- src/{ => video}/filters.ts | 6 +- src/{ => video}/opencl.ts | 2 +- src/{ => video}/vs-filters.ts | 10 +- src/{ => video}/vulkan.ts | 2 +- tests/fixtures/ass.ts | 2 +- tests/{ => fonts}/script-detect.test.ts | 2 +- tests/{ => settings}/settings-code.test.ts | 6 +- tests/{ => subtitles}/ass-classifier.test.ts | 4 +- tests/{ => subtitles}/ass-style.test.ts | 6 +- tests/{ => subtitles}/letter-signs.test.ts | 8 +- tests/{ => subtitles}/noise-signs.test.ts | 6 +- tests/{ => subtitles}/sign-clustering.test.ts | 10 +- tests/{ => subtitles}/subtitle-edit.test.ts | 6 +- tests/{ => subtitles}/subtitle-style.test.ts | 4 +- tests/{ => tracks}/ordering.test.ts | 6 +- tests/{ => tracks}/tracks-group.test.ts | 2 +- tests/{ => translate}/chunks.test.ts | 2 +- .../generic.test.ts} | 2 +- tests/{ => translate}/ollama.test.ts | 7 +- tests/{ => translate}/translate-dedup.test.ts | 4 +- tests/{ => translate}/translate-step.test.ts | 4 +- 71 files changed, 872 insertions(+), 798 deletions(-) create mode 100644 src/api/benchmark.ts create mode 100644 src/api/fonts.ts create mode 100644 src/api/index.ts create mode 100644 src/api/jobs.ts create mode 100644 src/api/library.ts create mode 100644 src/api/previews.ts create mode 100644 src/api/queue.ts create mode 100644 src/api/settings.ts create mode 100644 src/api/system.ts create mode 100644 src/api/translate.ts create mode 100644 src/api/vs-presets.ts rename src/{ => core}/concurrency.ts (100%) rename src/{ => core}/config.ts (97%) rename src/{ => core}/encoders.ts (96%) rename src/{ => core}/logger.ts (100%) rename src/{ => core}/mkv-tags.ts (98%) rename src/{ => core}/naming.ts (100%) rename src/{ => core}/process.ts (100%) rename src/{ => core}/system.ts (100%) rename src/{ => core}/types.ts (100%) rename src/{ => fonts}/font-instance.ts (98%) rename src/{ => fonts}/fonts.ts (99%) rename src/{ => fonts}/script-detect.ts (100%) rename src/{ => fonts}/system-fonts.ts (95%) rename src/{ => pipeline}/encoder.ts (98%) rename src/{ => pipeline}/preview-encoder.ts (96%) rename src/{ => pipeline}/probe.ts (98%) rename src/{ => pipeline}/source-analysis.ts (98%) rename src/{ => queue}/library.ts (98%) rename src/{ => queue}/store.ts (97%) rename src/{ => queue}/watcher.ts (97%) rename src/{ => settings}/settings-code.ts (99%) rename src/{ => subtitles}/ass-classifier.ts (100%) rename src/{ => subtitles}/ass-edit.ts (100%) rename src/{ => subtitles}/ass-style.ts (99%) rename src/{ => subtitles}/letter-signs.ts (100%) rename src/{ => subtitles}/srt-edit.ts (100%) rename src/{ => subtitles}/subtitle-style.ts (93%) rename src/{ => tracks}/tracks.ts (99%) rename src/{ => translate}/deepseek.ts (99%) rename src/{ollama-generic.ts => translate/generic.ts} (99%) rename src/{ => translate}/ollama.ts (99%) rename src/{ => translate}/subtitle-translate.ts (98%) rename src/{ => translate}/translate-languages.ts (99%) rename src/{ => translate}/translate-provider.ts (100%) rename src/{ => translate}/translate-step.ts (97%) rename src/{ => video}/auto-denoise.ts (99%) rename src/{ => video}/benchmark.ts (98%) rename src/{ => video}/color-metadata.ts (97%) rename src/{ => video}/filters.ts (99%) rename src/{ => video}/opencl.ts (97%) rename src/{ => video}/vs-filters.ts (98%) rename src/{ => video}/vulkan.ts (98%) rename tests/{ => fonts}/script-detect.test.ts (96%) rename tests/{ => settings}/settings-code.test.ts (80%) rename tests/{ => subtitles}/ass-classifier.test.ts (97%) rename tests/{ => subtitles}/ass-style.test.ts (98%) rename tests/{ => subtitles}/letter-signs.test.ts (98%) rename tests/{ => subtitles}/noise-signs.test.ts (98%) rename tests/{ => subtitles}/sign-clustering.test.ts (97%) rename tests/{ => subtitles}/subtitle-edit.test.ts (96%) rename tests/{ => subtitles}/subtitle-style.test.ts (95%) rename tests/{ => tracks}/ordering.test.ts (95%) rename tests/{ => tracks}/tracks-group.test.ts (99%) rename tests/{ => translate}/chunks.test.ts (97%) rename tests/{ollama-generic.test.ts => translate/generic.test.ts} (98%) rename tests/{ => translate}/ollama.test.ts (97%) rename tests/{ => translate}/translate-dedup.test.ts (98%) rename tests/{ => translate}/translate-step.test.ts (92%) diff --git a/src/api/benchmark.ts b/src/api/benchmark.ts new file mode 100644 index 0000000..eaba4ea --- /dev/null +++ b/src/api/benchmark.ts @@ -0,0 +1,24 @@ +import type { Web } from "@rabbit-company/web"; +import type { AppConfig } from "../core/types"; +import { cancelBenchmark, getBenchmarkState, startBenchmark } from "../video/benchmark"; + +export function registerBenchmarkRoutes(app: Web, config: AppConfig): void { + app.get("/api/benchmark", async (c) => { + return c.json(await getBenchmarkState(config.defaults.gpuDevice, config.defaults.denoiseBackend)); + }); + + app.post("/api/benchmark", async (c) => { + const result = await startBenchmark({ + gpuDevice: config.defaults.gpuDevice, + denoiseBackend: config.defaults.denoiseBackend, + }); + if (!result.ok) return c.json({ error: result.error || "Failed to start benchmark" }, 409); + return c.json(await getBenchmarkState(config.defaults.gpuDevice, config.defaults.denoiseBackend)); + }); + + app.delete("/api/benchmark", (c) => { + const ok = cancelBenchmark(); + if (!ok) return c.json({ error: "No benchmark currently running" }, 400); + return c.json({ ok: true }); + }); +} diff --git a/src/api/fonts.ts b/src/api/fonts.ts new file mode 100644 index 0000000..a3ed65b --- /dev/null +++ b/src/api/fonts.ts @@ -0,0 +1,125 @@ +import type { Web } from "@rabbit-company/web"; +import type { AppConfig } from "../core/types"; +import { fontRegistry } from "../fonts/fonts"; +import type { GroupStyleConfig } from "../subtitles/subtitle-style"; +import { isInsideRoots, listSystemFonts } from "../fonts/system-fonts"; +import { renameFontGroupReferences } from "../queue/store"; + +export function registerFontRoutes(app: Web, config: AppConfig): void { + app.get("/api/fonts", (c) => { + return c.json({ + fonts: fontRegistry.list().map((f) => ({ + label: f.label, + faces: f.faces.map((x) => ({ fileName: x.fileName, family: x.family, keys: x.keys, axes: x.axes })), + })), + }); + }); + + app.post("/api/fonts/reload", async (c) => { + await fontRegistry.reload(); + return c.json({ fonts: fontRegistry.list().map((f) => ({ label: f.label })) }); + }); + + app.get("/api/fonts/resolve", (c) => { + const label = c.query().get("family") || ""; + const lang = c.query().get("lang") || undefined; + const text = c.query().get("text") || ""; + const face = fontRegistry.resolve(label, lang, text); + return face ? c.json({ fileName: face.fileName, family: face.family }) : c.json({ fileName: null, family: null }); + }); + + app.get("/api/fonts/face/:family/:name", (c) => { + const face = fontRegistry.findFaceFile(decodeURIComponent(c.params.family!), decodeURIComponent(c.params.name!)); + if (!face) return c.json({ error: "Font not found" }, 404); + return new Response(Bun.file(face.path), { headers: { "Content-Type": face.mime, "Cache-Control": "private, max-age=300" } }); + }); + + app.get("/api/fonts/:label/style", (c) => { + const label = decodeURIComponent(c.params.label!); + const fam = fontRegistry.findFamily(label); + if (!fam) return c.json({ error: "Font group not found" }, 404); + const keys = [...new Set(fam.faces.flatMap((f) => f.keys))].sort(); + const cfg = fontRegistry.getGroupStyle(label); + return c.json({ style: cfg.style ?? {}, overrides: cfg.overrides ?? {}, keys }); + }); + + app.put("/api/fonts/:label/style", async (c) => { + const label = decodeURIComponent(c.params.label!); + if (!fontRegistry.findFamily(label)) return c.json({ error: "Font group not found" }, 404); + const body = (await c.req.json()) as { style?: unknown; overrides?: unknown }; + const ok = fontRegistry.saveGroupStyle(label, { + style: (body.style as GroupStyleConfig["style"]) ?? {}, + overrides: (body.overrides as GroupStyleConfig["overrides"]) ?? {}, + }); + if (!ok) return c.json({ error: "Failed to save group style" }, 500); + await fontRegistry.reload(); + return c.json({ ok: true }); + }); + + app.get("/api/system-fonts", async (c) => { + const roots = config.systemFontDirs; + if (roots.length === 0) return c.json({ roots: [], fonts: [], enabled: false }); + return c.json({ roots, fonts: await listSystemFonts(roots), enabled: true }); + }); + + app.post("/api/fonts/groups", async (c) => { + const body = (await c.req.json()) as { label?: string }; + if (typeof body.label !== "string" || !body.label.trim()) return c.json({ error: "Missing 'label'" }, 400); + const r = fontRegistry.createGroup(body.label); + if (!r.ok) return c.json({ error: r.error || "Failed to create group" }, 400); + await fontRegistry.reload(); + return c.json({ ok: true }); + }); + + app.patch("/api/fonts/groups/:label", async (c) => { + const oldLabel = decodeURIComponent(c.params.label!); + const body = (await c.req.json()) as { label?: string }; + if (typeof body.label !== "string" || !body.label.trim()) return c.json({ error: "Missing 'label'" }, 400); + const newLabel = body.label.trim(); + const r = fontRegistry.renameGroup(oldLabel, newLabel); + if (!r.ok) return c.json({ error: r.error || "Failed to rename group" }, 400); + const updatedReferences = renameFontGroupReferences(oldLabel, newLabel); + await fontRegistry.reload(); + return c.json({ ok: true, updatedReferences }); + }); + + app.delete("/api/fonts/groups/:label", async (c) => { + const label = decodeURIComponent(c.params.label!); + const r = fontRegistry.deleteGroup(label); + if (!r.ok) return c.json({ error: r.error || "Failed to delete group" }, 400); + await fontRegistry.reload(); + return c.json({ ok: true }); + }); + + app.post("/api/fonts/groups/:label/faces", async (c) => { + const label = decodeURIComponent(c.params.label!); + const body = (await c.req.json()) as { source?: string; keys?: string[] }; + if (typeof body.source !== "string" || !body.source) return c.json({ error: "Missing 'source'" }, 400); + if (!isInsideRoots(body.source, config.systemFontDirs)) return c.json({ error: "Source is not within a system font directory" }, 403); + const keys = Array.isArray(body.keys) ? body.keys.filter((k): k is string => typeof k === "string") : []; + const r = await fontRegistry.importFace(label, body.source, keys); + if (!r.ok) return c.json({ error: r.error || "Failed to import font" }, 400); + await fontRegistry.reload(); + return c.json({ ok: true, fileName: r.fileName }); + }); + + app.patch("/api/fonts/groups/:label/faces/:file", async (c) => { + const label = decodeURIComponent(c.params.label!); + const file = decodeURIComponent(c.params.file!); + const body = (await c.req.json()) as { keys?: string[]; family?: string }; + const keys = Array.isArray(body.keys) ? body.keys.filter((k): k is string => typeof k === "string") : []; + const r = fontRegistry.setFaceKeys(label, file, keys, typeof body.family === "string" ? body.family : undefined); + if (!r.ok) return c.json({ error: r.error || "Failed to update font" }, 400); + await fontRegistry.reload(); + return c.json({ ok: true }); + }); + + app.delete("/api/fonts/groups/:label/faces/:file", async (c) => { + const label = decodeURIComponent(c.params.label!); + const file = decodeURIComponent(c.params.file!); + const r = fontRegistry.deleteFace(label, file); + if (!r.ok) return c.json({ error: r.error || "Failed to delete font" }, 400); + await fontRegistry.reload(); + return c.json({ ok: true }); + }); +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..3651a3d --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,25 @@ +import type { Web } from "@rabbit-company/web"; +import { registerSystemRoutes } from "./system"; +import { registerJobRoutes } from "./jobs"; +import { registerPreviewRoutes } from "./previews"; +import { registerSettingsRoutes } from "./settings"; +import { registerTranslateRoutes } from "./translate"; +import { registerQueueRoutes } from "./queue"; +import { registerBenchmarkRoutes } from "./benchmark"; +import { registerLibraryRoutes } from "./library"; +import { registerFontRoutes } from "./fonts"; +import { registerVsPresetRoutes } from "./vs-presets"; +import type { AppConfig } from "../core/types"; + +export function registerApiRoutes(app: Web, config: AppConfig): void { + registerSystemRoutes(app, config); + registerJobRoutes(app, config); + registerPreviewRoutes(app, config); + registerSettingsRoutes(app, config); + registerTranslateRoutes(app); + registerQueueRoutes(app); + registerBenchmarkRoutes(app, config); + registerLibraryRoutes(app, config); + registerFontRoutes(app, config); + registerVsPresetRoutes(app); +} diff --git a/src/api/jobs.ts b/src/api/jobs.ts new file mode 100644 index 0000000..3ed6ac2 --- /dev/null +++ b/src/api/jobs.ts @@ -0,0 +1,204 @@ +import { existsSync, mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import type { Web } from "@rabbit-company/web"; +import type { AppConfig, JobSettings } from "../core/types"; +import { cancelJob, getAllJobs, getJob, moveJob, removeJob, reorderJobs, retryJob, updateJobSettings } from "../queue/store"; +import { probeFile } from "../pipeline/probe"; +import { previewAudio, previewSubtitles } from "../tracks/tracks"; +import { decodeSettingsCode, SettingsCodeError } from "../settings/settings-code"; + +export function registerJobRoutes(app: Web, config: AppConfig): void { + app.get("/api/jobs", (c) => { + return c.json(getAllJobs()); + }); + + app.get("/api/jobs/:id", (c) => { + const job = getJob(c.params.id!); + if (!job) return c.json({ error: "Job not found" }, 404); + return c.json(job); + }); + + app.patch("/api/jobs/:id", async (c) => { + const body = (await c.req.json()) as Partial; + const job = updateJobSettings(c.params.id!, body); + if (!job) return c.json({ error: "Job not found or not editable" }, 400); + return c.json(job); + }); + + app.delete("/api/jobs/:id", (c) => { + const ok = removeJob(c.params.id!); + if (!ok) return c.json({ error: "Cannot remove active job" }, 400); + return c.json({ ok: true }); + }); + + app.post("/api/jobs/:id/retry", (c) => { + const job = retryJob(c.params.id!); + if (!job) return c.json({ error: "Job not found or not retryable" }, 400); + return c.json(job); + }); + + app.post("/api/jobs/:id/cancel", (c) => { + const ok = cancelJob(c.params.id!); + if (!ok) return c.json({ error: "Job not found or not currently encoding" }, 400); + return c.json({ ok: true }); + }); + + app.get("/api/jobs/:id/subtitle-preview", async (c) => { + const job = getJob(c.params.id!); + if (!job) return c.json({ error: "Job not found" }, 404); + + if (!existsSync(job.inputPath)) { + return c.json({ error: "Source file no longer accessible" }, 400); + } + + let probe = job.probe; + if (!job.probe) { + probe = await probeFile(job.inputPath); + } + + const subtitleStreams = probe!.subtitleStreams || []; + if (subtitleStreams.length === 0) { + return c.json({ source: [], output: [] }); + } + + try { + const tempDir = mkdtempSync(join(config.tempDir, "sub-preview-")); + + const result = await previewSubtitles(job.inputPath, subtitleStreams, tempDir, { + dedupe: job.settings.dedupeSubtitles, + languages: job.settings.subtitleLanguages || [], + langDetect: job.settings.subtitleLangDetect, + langDetectConfidence: job.settings.subtitleLangDetectConfidence, + detectSignsSongs: job.settings.detectSignsSongs, + detectSDH: job.settings.detectSDH, + detectHonorifics: job.settings.detectHonorifics, + // Source / format ordering + sourcePriority: job.settings.subtitleSourcePriority, + fansubTiebreak: job.settings.subtitleFansubTiebreak, + formatPriority: job.settings.subtitleFormatPriority, + // Drop filters + dropPicture: job.settings.dropPictureSubtitles, + removeSDH: job.settings.removeSDHSubtitles, + removeCommentary: job.settings.removeCommentarySubtitles, + removeForcedSignsSongs: job.settings.removeForcedSignsSongs, + removeStoryboard: job.settings.removeStoryboardSubtitles, + removeHonorifics: job.settings.removeHonorificsSubtitles, + // Dedupe + naming + dedupeAcrossFormat: job.settings.dedupeAcrossFormat, + renameTracks: job.settings.renameSubtitleTracks, + // Advanced detection tuning + signsSongsStyleRatio: job.settings.signsSongsStyleRatio, + signsSongsLineRatio: job.settings.signsSongsLineRatio, + sdhRatioThreshold: job.settings.sdhRatioThreshold, + sdhMinLines: job.settings.sdhMinLines, + honorificsMinCount: job.settings.honorificsMinCount, + honorificsRatio: job.settings.honorificsRatio, + assumeMislabeled: job.settings.assumeMislabeledTracks, + }); + + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch {} + + return c.json(result); + } catch (err: any) { + return c.json({ error: `Preview failed: ${err.message || err}` }, 500); + } + }); + + app.get("/api/jobs/:id/audio-preview", async (c) => { + const job = getJob(c.params.id!); + if (!job) return c.json({ error: "Job not found" }, 404); + + if (!existsSync(job.inputPath)) { + return c.json({ error: "Source file no longer accessible" }, 400); + } + + let probe = job.probe; + if (!probe) { + probe = await probeFile(job.inputPath); + } + + const audioStreams = probe.audioStreams || []; + if (audioStreams.length === 0) { + return c.json({ source: [], output: [] }); + } + + try { + const result = previewAudio(audioStreams, job.settings.audioBitrates, { + languages: job.settings.audioLanguages || [], + languagePriority: job.settings.audioLanguagePriority, + collapseChannels: job.settings.keepBestAudioChannelsOnly, + dedupe: job.settings.dedupeAudio, + removeCommentary: job.settings.removeCommentaryAudio, + removeDescriptive: job.settings.removeDescriptiveAudio, + removeKaraoke: job.settings.removeKaraokeAudio, + dropCompatibility: job.settings.dropCompatibilityAudio, + codecPriority: job.settings.audioCodecPriority, + preferUncensored: job.settings.preferUncensoredAudio, + renameTracks: job.settings.renameAudioTracks, + detect: { + commentary: job.settings.detectCommentaryAudio, + descriptive: job.settings.detectDescriptiveAudio, + karaoke: job.settings.detectKaraokeAudio, + }, + }); + return c.json(result); + } catch (err: any) { + return c.json({ error: `Preview failed: ${err.message || err}` }, 500); + } + }); + + app.get("/api/jobs/:id/mediainfo", async (c) => { + const job = getJob(c.params.id!); + if (!job) return c.json({ error: "Job not found" }, 404); + + if (!existsSync(job.inputPath)) { + return c.json({ error: "Source file no longer accessible" }, 400); + } + + try { + const proc = Bun.spawn(["mediainfo", job.inputPath], { stdout: "pipe", stderr: "pipe" }); + const text = await new Response(proc.stdout).text(); + await proc.exited; + return c.json({ filename: job.filename, text: text.trim() }); + } catch (err: any) { + return c.json({ error: `mediainfo failed: ${err.message || err}` }, 500); + } + }); + + app.post("/api/jobs/:id/move", async (c) => { + const body = (await c.req.json()) as { direction?: string }; + const direction = body.direction; + if (!direction || !["up", "down", "top", "bottom"].includes(direction)) { + return c.json({ error: "Invalid direction. Use: up, down, top, bottom" }, 400); + } + const ok = moveJob(c.params.id!, direction as "up" | "down" | "top" | "bottom"); + if (!ok) return c.json({ error: "Job not found, not queued, or already at boundary" }, 400); + return c.json({ ok: true }); + }); + + app.post("/api/jobs/reorder", async (c) => { + const body = (await c.req.json()) as { ids?: string[] }; + if (!body.ids || !Array.isArray(body.ids)) { + return c.json({ error: "Missing 'ids' array in request body" }, 400); + } + reorderJobs(body.ids); + return c.json({ ok: true }); + }); + + app.post("/api/jobs/:id/import-code", async (c) => { + const body = (await c.req.json()) as { code?: string }; + if (typeof body.code !== "string") return c.json({ error: "Missing 'code' string" }, 400); + let partial; + try { + partial = decodeSettingsCode(body.code); + } catch (err) { + if (err instanceof SettingsCodeError) return c.json({ error: err.message }, 400); + throw err; + } + const job = updateJobSettings(c.params.id!, partial); + if (!job) return c.json({ error: "Job not found or not editable" }, 400); + return c.json(job); + }); +} diff --git a/src/api/library.ts b/src/api/library.ts new file mode 100644 index 0000000..a77c79b --- /dev/null +++ b/src/api/library.ts @@ -0,0 +1,60 @@ +import type { Web } from "@rabbit-company/web"; +import type { AppConfig } from "../core/types"; +import { browseFolder, isPathAllowed } from "../queue/library"; +import { Logger } from "../core/logger"; +import { scanLibraryPath } from "../queue/store"; + +export function registerLibraryRoutes(app: Web, config: AppConfig): void { + app.get("/api/library", (c) => { + return c.json({ + dirs: config.libraryDirs.map((dir) => ({ + path: dir, + name: dir.split("/").filter(Boolean).pop() || dir, + })), + }); + }); + + app.get("/api/library/browse", (c) => { + const path = c.query().get("path"); + if (!path) { + return c.json({ error: "Missing 'path' query parameter" }, 400); + } + + if (!isPathAllowed(path, config.libraryDirs)) { + return c.json({ error: "Path is not within any configured library directory" }, 403); + } + + const entries = browseFolder(path, config.organization); + return c.json({ path, entries }); + }); + + app.post("/api/library/encode", async (c) => { + const body = (await c.req.json()) as { paths?: string[]; path?: string }; + + const paths = body.paths || (body.path ? [body.path] : []); + if (paths.length === 0) { + return c.json({ error: "Missing 'paths' in request body" }, 400); + } + + for (const p of paths) { + if (!isPathAllowed(p, config.libraryDirs)) { + return c.json({ error: `Path is not within any configured library directory: ${p}` }, 403); + } + } + + let totalAdded = 0; + let totalSkipped = 0; + let totalAlreadyEncoded = 0; + + for (const p of paths) { + Logger.info(`[library] Encoding: ${p}`); + const result = scanLibraryPath(p); + totalAdded += result.added; + totalSkipped += result.skipped; + totalAlreadyEncoded += result.alreadyEncoded; + } + + Logger.info(`[library] Queued ${totalAdded} files (${totalSkipped} already queued, ${totalAlreadyEncoded} already encoded)`); + return c.json({ ok: true, added: totalAdded, skipped: totalSkipped, alreadyEncoded: totalAlreadyEncoded }); + }); +} diff --git a/src/api/previews.ts b/src/api/previews.ts new file mode 100644 index 0000000..673ffea --- /dev/null +++ b/src/api/previews.ts @@ -0,0 +1,90 @@ +import type { Web } from "@rabbit-company/web"; +import type { AppConfig } from "../core/types"; +import { cancelPreview, clearPreviewFor, getPreviewState, startPreview } from "../queue/store"; +import { resolvePreviewArtifact, type PreviewEncodeOptions } from "../pipeline/preview-encoder"; + +export function registerPreviewRoutes(app: Web, config: AppConfig): void { + app.get("/api/jobs/:id/preview", (c) => { + const state = getPreviewState(c.params.id!); + if (!state) return c.json({ status: "idle" }); + return c.json(state); + }); + + app.post("/api/jobs/:id/preview", async (c) => { + let body: { clipCount?: number; clipDuration?: number } = {}; + try { + body = (await c.req.json()) as typeof body; + } catch { + // no body (fall back to defaults) + } + + const options: Partial = {}; + + if (body.clipCount !== undefined) { + const n = Math.round(Number(body.clipCount)); + if (!Number.isFinite(n) || n < 1 || n > 20) { + return c.json({ error: "clipCount must be a whole number between 1 and 20" }, 400); + } + options.sampleCount = n; + } + + if (body.clipDuration !== undefined) { + const d = Number(body.clipDuration); + if (!Number.isFinite(d) || d < 1 || d > 30) { + return c.json({ error: "clipDuration must be between 1 and 30 seconds" }, 400); + } + options.windowSeconds = d; + } + + const result = startPreview(c.params.id!, options); + if (!result.ok) return c.json({ error: result.error }, result.status); + return c.json(result.state); + }); + + app.delete("/api/jobs/:id/preview", (c) => { + const cancelled = cancelPreview(c.params.id!); + if (!cancelled) { + clearPreviewFor(c.params.id!); + return c.json({ ok: true, cleared: true }); + } + return c.json({ ok: true, cancelled: true }); + }); + + app.get("/api/jobs/:id/preview/sample/:index/:kind", (c) => { + const jobId = c.params.id!; + const idx = parseInt(c.params.index!, 10); + const kind = c.params.kind!; + + if (Number.isNaN(idx)) return c.json({ error: "Bad request" }, 400); + + const isStandard = kind === "source" || kind === "encode" || kind === "clip" || kind === "source-clip"; + const isVsStep = /^vs:\d+$/.test(kind); + const isPrepareStep = /^pf:(?:downscale|deband|denoise|crop)$/.test(kind); + if (!isStandard && !isVsStep && !isPrepareStep) { + return c.json({ error: "Bad request" }, 400); + } + + const path = resolvePreviewArtifact(config, jobId, idx, kind); + if (!path) return c.json({ error: "Artifact not found" }, 404); + + const file = Bun.file(path); + + if (kind === "clip" || kind === "source-clip") { + const clipName = kind === "source-clip" ? "source" : "encode"; + return new Response(file, { + headers: { + "Content-Type": "video/x-matroska", + "Content-Disposition": `attachment; filename="job_${jobId}_sample_${idx + 1}_${clipName}.mkv"`, + "Cache-Control": "private, max-age=0, must-revalidate", + }, + }); + } + + return new Response(file, { + headers: { + "Content-Type": "image/png", + "Cache-Control": "private, max-age=60", + }, + }); + }); +} diff --git a/src/api/queue.ts b/src/api/queue.ts new file mode 100644 index 0000000..97b5937 --- /dev/null +++ b/src/api/queue.ts @@ -0,0 +1,20 @@ +import type { Web } from "@rabbit-company/web"; +import { isQueuePaused, pauseQueue, resumeQueue } from "../queue/store"; + +export function registerQueueRoutes(app: Web): void { + app.get("/api/queue", (c) => { + return c.json({ paused: isQueuePaused() }); + }); + + app.post("/api/queue/pause", (c) => { + const ok = pauseQueue(); + if (!ok) return c.json({ error: "Queue is already paused", paused: true }, 400); + return c.json({ ok: true, paused: true }); + }); + + app.post("/api/queue/resume", (c) => { + const ok = resumeQueue(); + if (!ok) return c.json({ error: "Queue is not paused", paused: false }, 400); + return c.json({ ok: true, paused: false }); + }); +} diff --git a/src/api/settings.ts b/src/api/settings.ts new file mode 100644 index 0000000..509b722 --- /dev/null +++ b/src/api/settings.ts @@ -0,0 +1,51 @@ +import type { Web } from "@rabbit-company/web"; +import type { AppConfig, JobSettings } from "../core/types"; +import { updateDefaults, resetDefaults } from "../queue/store"; +import { decodeSettingsCode, encodeSettingsCode, SettingsCodeError } from "../settings/settings-code"; + +export function registerSettingsRoutes(app: Web, config: AppConfig): void { + app.get("/api/config", (c) => { + return c.json(config.defaults); + }); + + app.patch("/api/config", async (c) => { + const body = (await c.req.json()) as Partial; + const updated = updateDefaults(body); + return c.json(updated); + }); + + app.post("/api/config/reset", (c) => { + return c.json(resetDefaults()); + }); + + app.post("/api/config/import-code", async (c) => { + const body = (await c.req.json()) as { code?: string }; + if (typeof body.code !== "string") return c.json({ error: "Missing 'code' string" }, 400); + try { + return c.json(updateDefaults(decodeSettingsCode(body.code))); + } catch (err) { + if (err instanceof SettingsCodeError) return c.json({ error: err.message }, 400); + throw err; + } + }); + + app.post("/api/settings/encode", async (c) => { + const body = (await c.req.json()) as Partial; + try { + return c.json({ code: encodeSettingsCode({ ...config.defaults, ...body } as JobSettings) }); + } catch { + return c.json({ code: "" }); + } + }); + + app.post("/api/settings/decode", async (c) => { + const body = (await c.req.json()) as { code?: string }; + if (typeof body.code !== "string") return c.json({ error: "Missing 'code' string" }, 400); + try { + return c.json({ settings: decodeSettingsCode(body.code) }); + } catch (err) { + if (err instanceof SettingsCodeError) return c.json({ error: err.message }, 400); + throw err; + } + }); +} diff --git a/src/api/system.ts b/src/api/system.ts new file mode 100644 index 0000000..5299e94 --- /dev/null +++ b/src/api/system.ts @@ -0,0 +1,22 @@ +import type { Web } from "@rabbit-company/web"; +import type { AppConfig } from "../core/types"; +import { getSystemStats } from "../core/system"; +import { listOpenClDevices } from "../video/opencl"; +import { listVulkanDevices } from "../video/vulkan"; + +export function registerSystemRoutes(app: Web, config: AppConfig): void { + app.get("/api/system", async (c) => { + const stats = await getSystemStats(config.tempDir); + return c.json(stats); + }); + + app.get("/api/opencl-devices", async (c) => { + const devices = await listOpenClDevices(); + return c.json({ devices }); + }); + + app.get("/api/vulkan-devices", async (c) => { + const devices = await listVulkanDevices(); + return c.json({ devices }); + }); +} diff --git a/src/api/translate.ts b/src/api/translate.ts new file mode 100644 index 0000000..dd5fc75 --- /dev/null +++ b/src/api/translate.ts @@ -0,0 +1,59 @@ +import type { Web } from "@rabbit-company/web"; +import { checkOllama, translateOne } from "../translate/ollama"; +import { resolveTranslateLang } from "../translate/translate-languages"; +import { checkGenericChat, checkGenericModel } from "../translate/generic"; +import { resolveTranslateStrategy, type TranslateProvider } from "../translate/translate-provider"; +import { checkDeepseek } from "../translate/deepseek"; + +export function registerTranslateRoutes(app: Web): void { + app.post("/api/translate/test", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as { + provider?: string; + url?: string; + model?: string; + apiKey?: string; + target?: string; + }; + const provider: TranslateProvider = body.provider === "deepseek" ? "deepseek" : "ollama"; + const model = (body.model || "").trim(); + const source = { name: "English", code: "en" }; + const target = resolveTranslateLang(body.target || "slv") ?? { name: "Slovenian", code: "sl" }; + + if (provider === "deepseek") { + const apiKey = (body.apiKey || "").trim(); + if (!apiKey || !model) return c.json({ ok: false, error: "Missing DeepSeek API key or model" }, 400); + + const health = await checkDeepseek(apiKey, model); + if (!health.ok) return c.json({ ok: false, error: health.detail }); + + const r = await checkGenericChat({ provider: "deepseek", url: "", apiKey, model, source, target }); + return c.json(r.ok ? { ok: true, sample: r.sample, model, target: target.name } : { ok: false, error: r.detail }); + } + + const url = (body.url || "").trim(); + if (!url || !model) return c.json({ ok: false, error: "Missing Ollama URL or model" }, 400); + + const health = await checkOllama(url, model); + if (!health.ok) return c.json({ ok: false, error: health.detail }); + + const strategy = resolveTranslateStrategy(provider, model); + + if (strategy === "generic") { + const r = await checkGenericModel(url, model, source, target); + return c.json(r.ok ? { ok: true, sample: r.sample, model, target: target.name } : { ok: false, error: r.detail }); + } + + try { + const sample = await translateOne("The goal of all life is death.", { + url, + model, + source, + target, + timeoutMs: 30000, + }); + return c.json({ ok: true, sample, model, target: target.name }); + } catch (err: any) { + return c.json({ ok: false, error: `Model reachable but translation failed: ${err?.message || err}` }); + } + }); +} diff --git a/src/api/vs-presets.ts b/src/api/vs-presets.ts new file mode 100644 index 0000000..f6ad5bd --- /dev/null +++ b/src/api/vs-presets.ts @@ -0,0 +1,19 @@ +import type { Web } from "@rabbit-company/web"; +import { makeDefaultVsFilterEntry, vsRegistry } from "../video/vs-filters"; + +export function registerVsPresetRoutes(app: Web): void { + app.get("/api/vs-presets", (c) => { + return c.json({ presets: vsRegistry.list() }); + }); + + app.post("/api/vs-presets/reload", (c) => { + vsRegistry.reload(); + return c.json({ ok: true, count: vsRegistry.list().length }); + }); + + app.get("/api/vs-presets/:id/default-entry", (c) => { + const entry = makeDefaultVsFilterEntry(c.params.id!); + if (!entry) return c.json({ error: "Unknown preset" }, 404); + return c.json(entry); + }); +} diff --git a/src/concurrency.ts b/src/core/concurrency.ts similarity index 100% rename from src/concurrency.ts rename to src/core/concurrency.ts diff --git a/src/config.ts b/src/core/config.ts similarity index 97% rename from src/config.ts rename to src/core/config.ts index f71236f..e28310c 100644 --- a/src/config.ts +++ b/src/core/config.ts @@ -1,5 +1,5 @@ -import { DEFAULT_AUTO_THRESHOLDS } from "./auto-denoise"; -import { DEFAULT_NLMEANS_PARAMS, DEFAULT_GRADFUN_PARAMS } from "./filters"; +import { DEFAULT_AUTO_THRESHOLDS } from "../video/auto-denoise"; +import { DEFAULT_NLMEANS_PARAMS, DEFAULT_GRADFUN_PARAMS } from "../video/filters"; import { Logger } from "./logger"; import { run } from "./process"; import type { AppConfig, AudioChannelBitrates, JobSettings } from "./types"; diff --git a/src/encoders.ts b/src/core/encoders.ts similarity index 96% rename from src/encoders.ts rename to src/core/encoders.ts index 1b25f8c..050764e 100644 --- a/src/encoders.ts +++ b/src/core/encoders.ts @@ -1,4 +1,4 @@ -import type { EncoderId } from "./types"; +import type { EncoderId } from "../core/types"; export interface EncoderDef { id: EncoderId; diff --git a/src/logger.ts b/src/core/logger.ts similarity index 100% rename from src/logger.ts rename to src/core/logger.ts diff --git a/src/mkv-tags.ts b/src/core/mkv-tags.ts similarity index 98% rename from src/mkv-tags.ts rename to src/core/mkv-tags.ts index 5219fe9..b5e6c3e 100644 --- a/src/mkv-tags.ts +++ b/src/core/mkv-tags.ts @@ -2,7 +2,7 @@ import { existsSync, unlinkSync } from "fs"; import { join } from "path"; import { run } from "./process"; import { Logger } from "./logger"; -import { decodeSettingsCode, SettingsCodeError } from "./settings-code"; +import { decodeSettingsCode, SettingsCodeError } from "../settings/settings-code"; import type { JobSettings } from "./types"; export interface InputMkvTags { diff --git a/src/naming.ts b/src/core/naming.ts similarity index 100% rename from src/naming.ts rename to src/core/naming.ts diff --git a/src/process.ts b/src/core/process.ts similarity index 100% rename from src/process.ts rename to src/core/process.ts diff --git a/src/system.ts b/src/core/system.ts similarity index 100% rename from src/system.ts rename to src/core/system.ts diff --git a/src/types.ts b/src/core/types.ts similarity index 100% rename from src/types.ts rename to src/core/types.ts diff --git a/src/font-instance.ts b/src/fonts/font-instance.ts similarity index 98% rename from src/font-instance.ts rename to src/fonts/font-instance.ts index af8be99..8cd4719 100644 --- a/src/font-instance.ts +++ b/src/fonts/font-instance.ts @@ -1,5 +1,5 @@ -import { run } from "./process"; -import { normalizeFontName } from "./ass-classifier"; +import { run } from "../core/process"; +import { normalizeFontName } from "../subtitles/ass-classifier"; const AXES_PY = ` import sys, json diff --git a/src/fonts.ts b/src/fonts/fonts.ts similarity index 99% rename from src/fonts.ts rename to src/fonts/fonts.ts index d139c73..595c9ac 100644 --- a/src/fonts.ts +++ b/src/fonts/fonts.ts @@ -1,11 +1,11 @@ import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "fs"; import { join, extname, basename, relative, resolve } from "path"; -import { run } from "./process"; -import { Logger } from "./logger"; -import { normalizeFontName } from "./ass-classifier"; +import { run } from "../core/process"; +import { Logger } from "../core/logger"; +import { normalizeFontName } from "../subtitles/ass-classifier"; import { detectScript, extractDialogueText, faceCandidateKeys } from "./script-detect"; import { readFontAxes, type FontAxis } from "./font-instance"; -import { resolveStyleAppearance, type GroupStyleConfig, type StyleAppearance } from "./subtitle-style"; +import { resolveStyleAppearance, type GroupStyleConfig, type StyleAppearance } from "../subtitles/subtitle-style"; const FONT_EXTS = new Set([".ttf", ".otf", ".ttc", ".otc"]); diff --git a/src/script-detect.ts b/src/fonts/script-detect.ts similarity index 100% rename from src/script-detect.ts rename to src/fonts/script-detect.ts diff --git a/src/system-fonts.ts b/src/fonts/system-fonts.ts similarity index 95% rename from src/system-fonts.ts rename to src/fonts/system-fonts.ts index 15f52ed..a6fb586 100644 --- a/src/system-fonts.ts +++ b/src/fonts/system-fonts.ts @@ -1,7 +1,7 @@ import { existsSync } from "fs"; import { extname, basename, resolve } from "path"; -import { run } from "./process"; -import { Logger } from "./logger"; +import { run } from "../core/process"; +import { Logger } from "../core/logger"; const FONT_EXTS = new Set([".ttf", ".otf", ".ttc", ".otc"]); diff --git a/src/index.ts b/src/index.ts index 726e3db..1ab4745 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,53 +1,15 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync } from "fs"; -import { loadConfig } from "./config"; -import { - initStore, - getAllJobs, - getJob, - updateJobSettings, - removeJob, - retryJob, - updateDefaults, - scanLibraryPath, - moveJob, - reorderJobs, - cancelJob, - isQueuePaused, - pauseQueue, - resumeQueue, - getPreviewState, - startPreview, - cancelPreview, - clearPreviewFor, - resetDefaults, - renameFontGroupReferences, -} from "./store"; -import { startWatcher } from "./watcher"; -import { browseFolder, isPathAllowed } from "./library"; +import { mkdirSync } from "fs"; import { Web } from "@rabbit-company/web"; import { cors } from "@rabbit-company/web-middleware/cors"; -import type { JobSettings } from "./types"; -import { Logger } from "./logger"; -import indexHtml from "../public/index.html"; import { bearerAuth } from "@rabbit-company/web-middleware/bearer-auth"; -import { previewAudio, previewSubtitles } from "./tracks"; -import { join } from "path"; -import { probeFile } from "./probe"; -import { cancelBenchmark, getBenchmarkState, startBenchmark } from "./benchmark"; -import { listOpenClDevices } from "./opencl"; -import { listVulkanDevices } from "./vulkan"; -import { resolvePreviewArtifact, type PreviewEncodeOptions } from "./preview-encoder"; -import { makeDefaultVsFilterEntry, vsRegistry } from "./vs-filters"; -import { decodeSettingsCode, encodeSettingsCode, SettingsCodeError } from "./settings-code"; -import { getSystemStats } from "./system"; -import { fontRegistry } from "./fonts"; -import type { GroupStyleConfig } from "./subtitle-style"; -import { isInsideRoots, listSystemFonts } from "./system-fonts"; -import { checkOllama, translateOne } from "./ollama"; -import { resolveTranslateLang } from "./translate-languages"; -import { checkGenericChat, checkGenericModel } from "./ollama-generic"; -import { resolveTranslateStrategy, type TranslateProvider } from "./translate-provider"; -import { checkDeepseek } from "./deepseek"; +import indexHtml from "../public/index.html"; +import { registerApiRoutes } from "./api"; +import { vsRegistry } from "./video/vs-filters"; +import { fontRegistry } from "./fonts/fonts"; +import { loadConfig } from "./core/config"; +import { initStore } from "./queue/store"; +import { startWatcher } from "./queue/watcher"; +import { Logger } from "./core/logger"; export const config = await loadConfig(); @@ -87,614 +49,7 @@ app.use( }), ); -app.get("/api/system", async (c) => { - const stats = await getSystemStats(config.tempDir); - return c.json(stats); -}); - -app.get("/api/opencl-devices", async (c) => { - const devices = await listOpenClDevices(); - return c.json({ devices }); -}); - -app.get("/api/vulkan-devices", async (c) => { - const devices = await listVulkanDevices(); - return c.json({ devices }); -}); - -app.get("/api/jobs", (c) => { - return c.json(getAllJobs()); -}); - -app.get("/api/jobs/:id", (c) => { - const job = getJob(c.params.id!); - if (!job) return c.json({ error: "Job not found" }, 404); - return c.json(job); -}); - -app.patch("/api/jobs/:id", async (c) => { - const body = (await c.req.json()) as Partial; - const job = updateJobSettings(c.params.id!, body); - if (!job) return c.json({ error: "Job not found or not editable" }, 400); - return c.json(job); -}); - -app.delete("/api/jobs/:id", (c) => { - const ok = removeJob(c.params.id!); - if (!ok) return c.json({ error: "Cannot remove active job" }, 400); - return c.json({ ok: true }); -}); - -app.post("/api/jobs/:id/retry", (c) => { - const job = retryJob(c.params.id!); - if (!job) return c.json({ error: "Job not found or not retryable" }, 400); - return c.json(job); -}); - -app.post("/api/jobs/:id/cancel", (c) => { - const ok = cancelJob(c.params.id!); - if (!ok) return c.json({ error: "Job not found or not currently encoding" }, 400); - return c.json({ ok: true }); -}); - -app.get("/api/jobs/:id/subtitle-preview", async (c) => { - const job = getJob(c.params.id!); - if (!job) return c.json({ error: "Job not found" }, 404); - - if (!existsSync(job.inputPath)) { - return c.json({ error: "Source file no longer accessible" }, 400); - } - - let probe = job.probe; - if (!job.probe) { - probe = await probeFile(job.inputPath); - } - - const subtitleStreams = probe!.subtitleStreams || []; - if (subtitleStreams.length === 0) { - return c.json({ source: [], output: [] }); - } - - try { - const tempDir = mkdtempSync(join(config.tempDir, "sub-preview-")); - - const result = await previewSubtitles(job.inputPath, subtitleStreams, tempDir, { - dedupe: job.settings.dedupeSubtitles, - languages: job.settings.subtitleLanguages || [], - langDetect: job.settings.subtitleLangDetect, - langDetectConfidence: job.settings.subtitleLangDetectConfidence, - detectSignsSongs: job.settings.detectSignsSongs, - detectSDH: job.settings.detectSDH, - detectHonorifics: job.settings.detectHonorifics, - // Source / format ordering - sourcePriority: job.settings.subtitleSourcePriority, - fansubTiebreak: job.settings.subtitleFansubTiebreak, - formatPriority: job.settings.subtitleFormatPriority, - // Drop filters - dropPicture: job.settings.dropPictureSubtitles, - removeSDH: job.settings.removeSDHSubtitles, - removeCommentary: job.settings.removeCommentarySubtitles, - removeForcedSignsSongs: job.settings.removeForcedSignsSongs, - removeStoryboard: job.settings.removeStoryboardSubtitles, - removeHonorifics: job.settings.removeHonorificsSubtitles, - // Dedupe + naming - dedupeAcrossFormat: job.settings.dedupeAcrossFormat, - renameTracks: job.settings.renameSubtitleTracks, - // Advanced detection tuning - signsSongsStyleRatio: job.settings.signsSongsStyleRatio, - signsSongsLineRatio: job.settings.signsSongsLineRatio, - sdhRatioThreshold: job.settings.sdhRatioThreshold, - sdhMinLines: job.settings.sdhMinLines, - honorificsMinCount: job.settings.honorificsMinCount, - honorificsRatio: job.settings.honorificsRatio, - assumeMislabeled: job.settings.assumeMislabeledTracks, - }); - - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch {} - - return c.json(result); - } catch (err: any) { - return c.json({ error: `Preview failed: ${err.message || err}` }, 500); - } -}); - -app.get("/api/jobs/:id/audio-preview", async (c) => { - const job = getJob(c.params.id!); - if (!job) return c.json({ error: "Job not found" }, 404); - - if (!existsSync(job.inputPath)) { - return c.json({ error: "Source file no longer accessible" }, 400); - } - - let probe = job.probe; - if (!probe) { - probe = await probeFile(job.inputPath); - } - - const audioStreams = probe.audioStreams || []; - if (audioStreams.length === 0) { - return c.json({ source: [], output: [] }); - } - - try { - const result = previewAudio(audioStreams, job.settings.audioBitrates, { - languages: job.settings.audioLanguages || [], - languagePriority: job.settings.audioLanguagePriority, - collapseChannels: job.settings.keepBestAudioChannelsOnly, - dedupe: job.settings.dedupeAudio, - removeCommentary: job.settings.removeCommentaryAudio, - removeDescriptive: job.settings.removeDescriptiveAudio, - removeKaraoke: job.settings.removeKaraokeAudio, - dropCompatibility: job.settings.dropCompatibilityAudio, - codecPriority: job.settings.audioCodecPriority, - preferUncensored: job.settings.preferUncensoredAudio, - renameTracks: job.settings.renameAudioTracks, - detect: { - commentary: job.settings.detectCommentaryAudio, - descriptive: job.settings.detectDescriptiveAudio, - karaoke: job.settings.detectKaraokeAudio, - }, - }); - return c.json(result); - } catch (err: any) { - return c.json({ error: `Preview failed: ${err.message || err}` }, 500); - } -}); - -app.get("/api/jobs/:id/mediainfo", async (c) => { - const job = getJob(c.params.id!); - if (!job) return c.json({ error: "Job not found" }, 404); - - if (!existsSync(job.inputPath)) { - return c.json({ error: "Source file no longer accessible" }, 400); - } - - try { - const proc = Bun.spawn(["mediainfo", job.inputPath], { stdout: "pipe", stderr: "pipe" }); - const text = await new Response(proc.stdout).text(); - await proc.exited; - return c.json({ filename: job.filename, text: text.trim() }); - } catch (err: any) { - return c.json({ error: `mediainfo failed: ${err.message || err}` }, 500); - } -}); - -app.post("/api/jobs/:id/move", async (c) => { - const body = (await c.req.json()) as { direction?: string }; - const direction = body.direction; - if (!direction || !["up", "down", "top", "bottom"].includes(direction)) { - return c.json({ error: "Invalid direction. Use: up, down, top, bottom" }, 400); - } - const ok = moveJob(c.params.id!, direction as "up" | "down" | "top" | "bottom"); - if (!ok) return c.json({ error: "Job not found, not queued, or already at boundary" }, 400); - return c.json({ ok: true }); -}); - -app.post("/api/jobs/reorder", async (c) => { - const body = (await c.req.json()) as { ids?: string[] }; - if (!body.ids || !Array.isArray(body.ids)) { - return c.json({ error: "Missing 'ids' array in request body" }, 400); - } - reorderJobs(body.ids); - return c.json({ ok: true }); -}); - -app.get("/api/jobs/:id/preview", (c) => { - const state = getPreviewState(c.params.id!); - if (!state) return c.json({ status: "idle" }); - return c.json(state); -}); - -app.post("/api/jobs/:id/preview", async (c) => { - let body: { clipCount?: number; clipDuration?: number } = {}; - try { - body = (await c.req.json()) as typeof body; - } catch { - // no body (fall back to defaults) - } - - const options: Partial = {}; - - if (body.clipCount !== undefined) { - const n = Math.round(Number(body.clipCount)); - if (!Number.isFinite(n) || n < 1 || n > 20) { - return c.json({ error: "clipCount must be a whole number between 1 and 20" }, 400); - } - options.sampleCount = n; - } - - if (body.clipDuration !== undefined) { - const d = Number(body.clipDuration); - if (!Number.isFinite(d) || d < 1 || d > 30) { - return c.json({ error: "clipDuration must be between 1 and 30 seconds" }, 400); - } - options.windowSeconds = d; - } - - const result = startPreview(c.params.id!, options); - if (!result.ok) return c.json({ error: result.error }, result.status); - return c.json(result.state); -}); - -app.delete("/api/jobs/:id/preview", (c) => { - const cancelled = cancelPreview(c.params.id!); - if (!cancelled) { - clearPreviewFor(c.params.id!); - return c.json({ ok: true, cleared: true }); - } - return c.json({ ok: true, cancelled: true }); -}); - -app.get("/api/jobs/:id/preview/sample/:index/:kind", (c) => { - const jobId = c.params.id!; - const idx = parseInt(c.params.index!, 10); - const kind = c.params.kind!; - - if (Number.isNaN(idx)) return c.json({ error: "Bad request" }, 400); - - const isStandard = kind === "source" || kind === "encode" || kind === "clip" || kind === "source-clip"; - const isVsStep = /^vs:\d+$/.test(kind); - const isPrepareStep = /^pf:(?:downscale|deband|denoise|crop)$/.test(kind); - if (!isStandard && !isVsStep && !isPrepareStep) { - return c.json({ error: "Bad request" }, 400); - } - - const path = resolvePreviewArtifact(config, jobId, idx, kind); - if (!path) return c.json({ error: "Artifact not found" }, 404); - - const file = Bun.file(path); - - if (kind === "clip" || kind === "source-clip") { - const clipName = kind === "source-clip" ? "source" : "encode"; - return new Response(file, { - headers: { - "Content-Type": "video/x-matroska", - "Content-Disposition": `attachment; filename="job_${jobId}_sample_${idx + 1}_${clipName}.mkv"`, - "Cache-Control": "private, max-age=0, must-revalidate", - }, - }); - } - - return new Response(file, { - headers: { - "Content-Type": "image/png", - "Cache-Control": "private, max-age=60", - }, - }); -}); - -app.get("/api/config", (c) => { - return c.json(config.defaults); -}); - -app.patch("/api/config", async (c) => { - const body = (await c.req.json()) as Partial; - const updated = updateDefaults(body); - return c.json(updated); -}); - -app.post("/api/config/reset", (c) => { - return c.json(resetDefaults()); -}); - -app.post("/api/config/import-code", async (c) => { - const body = (await c.req.json()) as { code?: string }; - if (typeof body.code !== "string") return c.json({ error: "Missing 'code' string" }, 400); - try { - return c.json(updateDefaults(decodeSettingsCode(body.code))); - } catch (err) { - if (err instanceof SettingsCodeError) return c.json({ error: err.message }, 400); - throw err; - } -}); - -app.post("/api/translate/test", async (c) => { - const body = (await c.req.json().catch(() => ({}))) as { - provider?: string; - url?: string; - model?: string; - apiKey?: string; - target?: string; - }; - const provider: TranslateProvider = body.provider === "deepseek" ? "deepseek" : "ollama"; - const model = (body.model || "").trim(); - const source = { name: "English", code: "en" }; - const target = resolveTranslateLang(body.target || "slv") ?? { name: "Slovenian", code: "sl" }; - - if (provider === "deepseek") { - const apiKey = (body.apiKey || "").trim(); - if (!apiKey || !model) return c.json({ ok: false, error: "Missing DeepSeek API key or model" }, 400); - - const health = await checkDeepseek(apiKey, model); - if (!health.ok) return c.json({ ok: false, error: health.detail }); - - const r = await checkGenericChat({ provider: "deepseek", url: "", apiKey, model, source, target }); - return c.json(r.ok ? { ok: true, sample: r.sample, model, target: target.name } : { ok: false, error: r.detail }); - } - - const url = (body.url || "").trim(); - if (!url || !model) return c.json({ ok: false, error: "Missing Ollama URL or model" }, 400); - - const health = await checkOllama(url, model); - if (!health.ok) return c.json({ ok: false, error: health.detail }); - - const strategy = resolveTranslateStrategy(provider, model); - - if (strategy === "generic") { - const r = await checkGenericModel(url, model, source, target); - return c.json(r.ok ? { ok: true, sample: r.sample, model, target: target.name } : { ok: false, error: r.detail }); - } - - try { - const sample = await translateOne("The goal of all life is death.", { - url, - model, - source, - target, - timeoutMs: 30000, - }); - return c.json({ ok: true, sample, model, target: target.name }); - } catch (err: any) { - return c.json({ ok: false, error: `Model reachable but translation failed: ${err?.message || err}` }); - } -}); - -app.get("/api/fonts", (c) => { - return c.json({ - fonts: fontRegistry.list().map((f) => ({ - label: f.label, - faces: f.faces.map((x) => ({ fileName: x.fileName, family: x.family, keys: x.keys, axes: x.axes })), - })), - }); -}); - -app.post("/api/fonts/reload", async (c) => { - await fontRegistry.reload(); - return c.json({ fonts: fontRegistry.list().map((f) => ({ label: f.label })) }); -}); - -app.get("/api/fonts/resolve", (c) => { - const label = c.query().get("family") || ""; - const lang = c.query().get("lang") || undefined; - const text = c.query().get("text") || ""; - const face = fontRegistry.resolve(label, lang, text); - return face ? c.json({ fileName: face.fileName, family: face.family }) : c.json({ fileName: null, family: null }); -}); - -app.get("/api/fonts/face/:family/:name", (c) => { - const face = fontRegistry.findFaceFile(decodeURIComponent(c.params.family!), decodeURIComponent(c.params.name!)); - if (!face) return c.json({ error: "Font not found" }, 404); - return new Response(Bun.file(face.path), { headers: { "Content-Type": face.mime, "Cache-Control": "private, max-age=300" } }); -}); - -app.get("/api/fonts/:label/style", (c) => { - const label = decodeURIComponent(c.params.label!); - const fam = fontRegistry.findFamily(label); - if (!fam) return c.json({ error: "Font group not found" }, 404); - const keys = [...new Set(fam.faces.flatMap((f) => f.keys))].sort(); - const cfg = fontRegistry.getGroupStyle(label); - return c.json({ style: cfg.style ?? {}, overrides: cfg.overrides ?? {}, keys }); -}); - -app.put("/api/fonts/:label/style", async (c) => { - const label = decodeURIComponent(c.params.label!); - if (!fontRegistry.findFamily(label)) return c.json({ error: "Font group not found" }, 404); - const body = (await c.req.json()) as { style?: unknown; overrides?: unknown }; - const ok = fontRegistry.saveGroupStyle(label, { - style: (body.style as GroupStyleConfig["style"]) ?? {}, - overrides: (body.overrides as GroupStyleConfig["overrides"]) ?? {}, - }); - if (!ok) return c.json({ error: "Failed to save group style" }, 500); - await fontRegistry.reload(); - return c.json({ ok: true }); -}); - -app.get("/api/system-fonts", async (c) => { - const roots = config.systemFontDirs; - if (roots.length === 0) return c.json({ roots: [], fonts: [], enabled: false }); - return c.json({ roots, fonts: await listSystemFonts(roots), enabled: true }); -}); - -app.post("/api/fonts/groups", async (c) => { - const body = (await c.req.json()) as { label?: string }; - if (typeof body.label !== "string" || !body.label.trim()) return c.json({ error: "Missing 'label'" }, 400); - const r = fontRegistry.createGroup(body.label); - if (!r.ok) return c.json({ error: r.error || "Failed to create group" }, 400); - await fontRegistry.reload(); - return c.json({ ok: true }); -}); - -app.patch("/api/fonts/groups/:label", async (c) => { - const oldLabel = decodeURIComponent(c.params.label!); - const body = (await c.req.json()) as { label?: string }; - if (typeof body.label !== "string" || !body.label.trim()) return c.json({ error: "Missing 'label'" }, 400); - const newLabel = body.label.trim(); - const r = fontRegistry.renameGroup(oldLabel, newLabel); - if (!r.ok) return c.json({ error: r.error || "Failed to rename group" }, 400); - const updatedReferences = renameFontGroupReferences(oldLabel, newLabel); - await fontRegistry.reload(); - return c.json({ ok: true, updatedReferences }); -}); - -app.delete("/api/fonts/groups/:label", async (c) => { - const label = decodeURIComponent(c.params.label!); - const r = fontRegistry.deleteGroup(label); - if (!r.ok) return c.json({ error: r.error || "Failed to delete group" }, 400); - await fontRegistry.reload(); - return c.json({ ok: true }); -}); - -app.post("/api/fonts/groups/:label/faces", async (c) => { - const label = decodeURIComponent(c.params.label!); - const body = (await c.req.json()) as { source?: string; keys?: string[] }; - if (typeof body.source !== "string" || !body.source) return c.json({ error: "Missing 'source'" }, 400); - if (!isInsideRoots(body.source, config.systemFontDirs)) return c.json({ error: "Source is not within a system font directory" }, 403); - const keys = Array.isArray(body.keys) ? body.keys.filter((k): k is string => typeof k === "string") : []; - const r = await fontRegistry.importFace(label, body.source, keys); - if (!r.ok) return c.json({ error: r.error || "Failed to import font" }, 400); - await fontRegistry.reload(); - return c.json({ ok: true, fileName: r.fileName }); -}); - -app.patch("/api/fonts/groups/:label/faces/:file", async (c) => { - const label = decodeURIComponent(c.params.label!); - const file = decodeURIComponent(c.params.file!); - const body = (await c.req.json()) as { keys?: string[]; family?: string }; - const keys = Array.isArray(body.keys) ? body.keys.filter((k): k is string => typeof k === "string") : []; - const r = fontRegistry.setFaceKeys(label, file, keys, typeof body.family === "string" ? body.family : undefined); - if (!r.ok) return c.json({ error: r.error || "Failed to update font" }, 400); - await fontRegistry.reload(); - return c.json({ ok: true }); -}); - -app.delete("/api/fonts/groups/:label/faces/:file", async (c) => { - const label = decodeURIComponent(c.params.label!); - const file = decodeURIComponent(c.params.file!); - const r = fontRegistry.deleteFace(label, file); - if (!r.ok) return c.json({ error: r.error || "Failed to delete font" }, 400); - await fontRegistry.reload(); - return c.json({ ok: true }); -}); - -app.post("/api/jobs/:id/import-code", async (c) => { - const body = (await c.req.json()) as { code?: string }; - if (typeof body.code !== "string") return c.json({ error: "Missing 'code' string" }, 400); - let partial; - try { - partial = decodeSettingsCode(body.code); - } catch (err) { - if (err instanceof SettingsCodeError) return c.json({ error: err.message }, 400); - throw err; - } - const job = updateJobSettings(c.params.id!, partial); - if (!job) return c.json({ error: "Job not found or not editable" }, 400); - return c.json(job); -}); - -app.post("/api/settings/encode", async (c) => { - const body = (await c.req.json()) as Partial; - try { - return c.json({ code: encodeSettingsCode({ ...config.defaults, ...body } as JobSettings) }); - } catch { - return c.json({ code: "" }); - } -}); - -app.post("/api/settings/decode", async (c) => { - const body = (await c.req.json()) as { code?: string }; - if (typeof body.code !== "string") return c.json({ error: "Missing 'code' string" }, 400); - try { - return c.json({ settings: decodeSettingsCode(body.code) }); - } catch (err) { - if (err instanceof SettingsCodeError) return c.json({ error: err.message }, 400); - throw err; - } -}); - -app.get("/api/queue", (c) => { - return c.json({ paused: isQueuePaused() }); -}); - -app.post("/api/queue/pause", (c) => { - const ok = pauseQueue(); - if (!ok) return c.json({ error: "Queue is already paused", paused: true }, 400); - return c.json({ ok: true, paused: true }); -}); - -app.post("/api/queue/resume", (c) => { - const ok = resumeQueue(); - if (!ok) return c.json({ error: "Queue is not paused", paused: false }, 400); - return c.json({ ok: true, paused: false }); -}); - -app.get("/api/benchmark", async (c) => { - return c.json(await getBenchmarkState(config.defaults.gpuDevice, config.defaults.denoiseBackend)); -}); - -app.post("/api/benchmark", async (c) => { - const result = await startBenchmark({ - gpuDevice: config.defaults.gpuDevice, - denoiseBackend: config.defaults.denoiseBackend, - }); - if (!result.ok) return c.json({ error: result.error || "Failed to start benchmark" }, 409); - return c.json(await getBenchmarkState(config.defaults.gpuDevice, config.defaults.denoiseBackend)); -}); - -app.delete("/api/benchmark", (c) => { - const ok = cancelBenchmark(); - if (!ok) return c.json({ error: "No benchmark currently running" }, 400); - return c.json({ ok: true }); -}); - -app.get("/api/library", (c) => { - return c.json({ - dirs: config.libraryDirs.map((dir) => ({ - path: dir, - name: dir.split("/").filter(Boolean).pop() || dir, - })), - }); -}); - -app.get("/api/library/browse", (c) => { - const path = c.query().get("path"); - if (!path) { - return c.json({ error: "Missing 'path' query parameter" }, 400); - } - - if (!isPathAllowed(path, config.libraryDirs)) { - return c.json({ error: "Path is not within any configured library directory" }, 403); - } - - const entries = browseFolder(path, config.organization); - return c.json({ path, entries }); -}); - -app.post("/api/library/encode", async (c) => { - const body = (await c.req.json()) as { paths?: string[]; path?: string }; - - const paths = body.paths || (body.path ? [body.path] : []); - if (paths.length === 0) { - return c.json({ error: "Missing 'paths' in request body" }, 400); - } - - for (const p of paths) { - if (!isPathAllowed(p, config.libraryDirs)) { - return c.json({ error: `Path is not within any configured library directory: ${p}` }, 403); - } - } - - let totalAdded = 0; - let totalSkipped = 0; - let totalAlreadyEncoded = 0; - - for (const p of paths) { - Logger.info(`[library] Encoding: ${p}`); - const result = scanLibraryPath(p); - totalAdded += result.added; - totalSkipped += result.skipped; - totalAlreadyEncoded += result.alreadyEncoded; - } - - Logger.info(`[library] Queued ${totalAdded} files (${totalSkipped} already queued, ${totalAlreadyEncoded} already encoded)`); - return c.json({ ok: true, added: totalAdded, skipped: totalSkipped, alreadyEncoded: totalAlreadyEncoded }); -}); - -app.get("/api/vs-presets", (c) => { - return c.json({ presets: vsRegistry.list() }); -}); - -app.post("/api/vs-presets/reload", (c) => { - vsRegistry.reload(); - return c.json({ ok: true, count: vsRegistry.list().length }); -}); - -app.get("/api/vs-presets/:id/default-entry", (c) => { - const entry = makeDefaultVsFilterEntry(c.params.id!); - if (!entry) return c.json({ error: "Unknown preset" }, 404); - return c.json(entry); -}); +registerApiRoutes(app, config); Logger.info(`Rabbit Encoder started on http://0.0.0.0:${config.port}`); diff --git a/src/encoder.ts b/src/pipeline/encoder.ts similarity index 98% rename from src/encoder.ts rename to src/pipeline/encoder.ts index 812dcea..023e97c 100644 --- a/src/encoder.ts +++ b/src/pipeline/encoder.ts @@ -1,9 +1,9 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync, rmSync, readdirSync, symlinkSync, renameSync } from "fs"; import { join, parse as parsePath, dirname, extname, basename, resolve } from "path"; -import type { Job, JobStep, AppConfig, EncodeJobOptions, SubtitleBurnMode, AudioStreamInfo, SubtitleStreamInfo } from "./types"; +import type { Job, JobStep, AppConfig, EncodeJobOptions, SubtitleBurnMode, AudioStreamInfo, SubtitleStreamInfo } from "../core/types"; import { probeFile, getOpusBitrateForLayout, getAudioReplacementLabel, normalizeLayout } from "./probe"; -import { Logger } from "./logger"; -import { CancelledError, run, humanSize, fmtFrames, pct2, escapeXml, describeExitCode, isTimecodesVFR, computeFps } from "./process"; +import { Logger } from "../core/logger"; +import { CancelledError, run, humanSize, fmtFrames, pct2, escapeXml, describeExitCode, isTimecodesVFR, computeFps } from "../core/process"; import { detectAudioTrackType, sortAudioStreams, @@ -20,25 +20,25 @@ import { filterAudioTypes, buildAudioTrackName, isTextSubtitleCodec, -} from "./tracks"; -import { styleSrtAss, restyleAssDialogueFont } from "./ass-style"; -import { extractUsedFonts, normalizeFontName } from "./ass-classifier"; -import { fontRegistry, buildKeptAttachmentArgs, scanMkvAttachmentFontNames, type ResolvedFace } from "./fonts"; -import { detectSourceTag, detectReleaseGroup, getResolutionTag, extractBaseTitle, inferSourceFromStream } from "./naming"; -import pkg from "../package.json"; -import { buildPrepareFilterConfig } from "./filters"; -import { FFV1_ENCODE_ARGS, runAnalysisPass, runSegmentedAutoDenoiseGpu, type DenoisePlan } from "./auto-denoise"; -import { formatVsProgressDetail, runVsPass, vsRegistry } from "./vs-filters"; -import { applyColorMetadata, svtColorParamsFromProbe } from "./color-metadata"; -import { combineCumulativeSettings, encodeSettingsCode } from "./settings-code"; -import { decodePriorSettings } from "./mkv-tags"; +} from "../tracks/tracks"; +import { styleSrtAss, restyleAssDialogueFont } from "../subtitles/ass-style"; +import { extractUsedFonts, normalizeFontName } from "../subtitles/ass-classifier"; +import { fontRegistry, buildKeptAttachmentArgs, scanMkvAttachmentFontNames, type ResolvedFace } from "../fonts/fonts"; +import { detectSourceTag, detectReleaseGroup, getResolutionTag, extractBaseTitle, inferSourceFromStream } from "../core/naming"; +import pkg from "../../package.json"; +import { buildPrepareFilterConfig } from "../video/filters"; +import { FFV1_ENCODE_ARGS, runAnalysisPass, runSegmentedAutoDenoiseGpu, type DenoisePlan } from "../video/auto-denoise"; +import { formatVsProgressDetail, runVsPass, vsRegistry } from "../video/vs-filters"; +import { applyColorMetadata, svtColorParamsFromProbe } from "../video/color-metadata"; +import { combineCumulativeSettings, encodeSettingsCode } from "../settings/settings-code"; +import { decodePriorSettings } from "../core/mkv-tags"; import { cpus } from "os"; -import { getEncoder } from "./encoders"; -import { axisSuffix, chooseAvailableFontFamily, fontAttachmentFileName, instancedFontNames, instanceFont } from "./font-instance"; -import { DEFAULT_STYLE_APPEARANCE, type StyleAppearance } from "./subtitle-style"; -import { runTranslateStep, orderOutputSubtitles, type TranslatedTrack } from "./translate-step"; +import { getEncoder } from "../core/encoders"; +import { axisSuffix, chooseAvailableFontFamily, fontAttachmentFileName, instancedFontNames, instanceFont } from "../fonts/font-instance"; +import { DEFAULT_STYLE_APPEARANCE, type StyleAppearance } from "../subtitles/subtitle-style"; +import { runTranslateStep, orderOutputSubtitles, type TranslatedTrack } from "../translate/translate-step"; -export { CancelledError } from "./process"; +export { CancelledError } from "../core/process"; const S_PROBE = 0; const S_PREPARE = 1; diff --git a/src/preview-encoder.ts b/src/pipeline/preview-encoder.ts similarity index 96% rename from src/preview-encoder.ts rename to src/pipeline/preview-encoder.ts index 7358111..f3d9b7f 100644 --- a/src/preview-encoder.ts +++ b/src/pipeline/preview-encoder.ts @@ -1,13 +1,23 @@ import { existsSync, mkdirSync, rmSync, statSync } from "fs"; import { join } from "path"; -import type { AppConfig, Job, JobSettings, PreviewFrameSink, SubtitleBurnMode, PreviewSample, PreviewSampleVsFrame, PreviewState, ProbeResult } from "./types"; +import type { + AppConfig, + Job, + JobSettings, + PreviewFrameSink, + SubtitleBurnMode, + PreviewSample, + PreviewSampleVsFrame, + PreviewState, + ProbeResult, +} from "../core/types"; import { probeFile } from "./probe"; -import { CancelledError, humanSize, run } from "./process"; -import { Logger } from "./logger"; +import { CancelledError, humanSize, run } from "../core/process"; +import { Logger } from "../core/logger"; import { encodeJob } from "./encoder"; import { analyzeSourceTracks } from "./source-analysis"; -import { FFV1_ENCODE_ARGS } from "./auto-denoise"; -import { vsRegistry } from "./vs-filters"; +import { FFV1_ENCODE_ARGS } from "../video/auto-denoise"; +import { vsRegistry } from "../video/vs-filters"; export interface PreviewEncodeOptions { sampleCount: number; @@ -39,7 +49,6 @@ export function previewSettingsFingerprint(s: JobSettings): string { convertSrtToAss: s.convertSrtToAss, restyleAssFont: s.restyleAssFont, removeUnusedFonts: s.removeUnusedFonts, - subtitleStyle: s.subtitleStyle, }); } diff --git a/src/probe.ts b/src/pipeline/probe.ts similarity index 98% rename from src/probe.ts rename to src/pipeline/probe.ts index 31caa6d..0508fdd 100644 --- a/src/probe.ts +++ b/src/pipeline/probe.ts @@ -1,6 +1,6 @@ -import { readMkvTags } from "./mkv-tags"; +import { readMkvTags } from "../core/mkv-tags"; import { tmpdir } from "os"; -import type { AudioStreamInfo, SubtitleStreamInfo, AudioChannelBitrates, ProbeResult } from "./types"; +import type { AudioStreamInfo, SubtitleStreamInfo, AudioChannelBitrates, ProbeResult } from "../core/types"; async function exec(cmd: string[]): Promise { const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }); diff --git a/src/source-analysis.ts b/src/pipeline/source-analysis.ts similarity index 98% rename from src/source-analysis.ts rename to src/pipeline/source-analysis.ts index 5320197..f5229dd 100644 --- a/src/source-analysis.ts +++ b/src/pipeline/source-analysis.ts @@ -1,4 +1,4 @@ -import type { JobSettings, ProbeResult, SourceTrackPlan } from "./types"; +import type { JobSettings, ProbeResult, SourceTrackPlan } from "../core/types"; import { analyzeSubtitleStreams, sortSubtitleStreams, @@ -8,8 +8,8 @@ import { filterAudioTypes, sortAudioStreams, deduplicateAudioStreams, -} from "./tracks"; -import { Logger } from "./logger"; +} from "../tracks/tracks"; +import { Logger } from "../core/logger"; export async function analyzeSourceTracks( probe: ProbeResult, diff --git a/src/library.ts b/src/queue/library.ts similarity index 98% rename from src/library.ts rename to src/queue/library.ts index ff0f7f0..def7f73 100644 --- a/src/library.ts +++ b/src/queue/library.ts @@ -1,6 +1,6 @@ import { readdirSync, statSync, existsSync } from "fs"; import { join, resolve, extname, parse as parsePath } from "path"; -import { MEDIA_EXTENSIONS } from "./types"; +import { MEDIA_EXTENSIONS } from "../core/types"; export interface LibraryEntry { name: string; diff --git a/src/store.ts b/src/queue/store.ts similarity index 97% rename from src/store.ts rename to src/queue/store.ts index ca378da..2c04389 100644 --- a/src/store.ts +++ b/src/queue/store.ts @@ -11,15 +11,21 @@ import { type AudioEncodeMode, type SubtitleProcessingMode, type EncoderId, -} from "./types"; -import { encodeJob, CancelledError } from "./encoder"; +} from "../core/types"; +import { encodeJob, CancelledError } from "../pipeline/encoder"; import { isAlreadyEncoded } from "./library"; -import { Logger } from "./logger"; -import { normalizeNlmeansLevelParams, normalizeGradfunLevelParams } from "./filters"; -import { runPreviewEncode, deletePreviewDir, previewSettingsFingerprint, DEFAULT_PREVIEW_OPTIONS, type PreviewEncodeOptions } from "./preview-encoder"; -import { normalizeVsFilterChain } from "./vs-filters"; -import { getDefaultJobSettings } from "./config"; -import { isValidEncoder } from "./encoders"; +import { Logger } from "../core/logger"; +import { normalizeNlmeansLevelParams, normalizeGradfunLevelParams } from "../video/filters"; +import { + runPreviewEncode, + deletePreviewDir, + previewSettingsFingerprint, + DEFAULT_PREVIEW_OPTIONS, + type PreviewEncodeOptions, +} from "../pipeline/preview-encoder"; +import { normalizeVsFilterChain } from "../video/vs-filters"; +import { getDefaultJobSettings } from "../core/config"; +import { isValidEncoder } from "../core/encoders"; const jobs = new Map(); let paused = false; diff --git a/src/watcher.ts b/src/queue/watcher.ts similarity index 97% rename from src/watcher.ts rename to src/queue/watcher.ts index 02ebb34..e44335b 100644 --- a/src/watcher.ts +++ b/src/queue/watcher.ts @@ -1,8 +1,8 @@ import { watch, readdirSync, existsSync, statSync } from "fs"; import { join, extname, relative, dirname } from "path"; import { addJob } from "./store"; -import { Logger } from "./logger"; -import { MEDIA_EXTENSIONS } from "./types"; +import { Logger } from "../core/logger"; +import { MEDIA_EXTENSIONS } from "../core/types"; const COOLDOWN_SEC = parseInt(process.env.FILE_COOLDOWN || "300"); const POLL_INTERVAL = 10; diff --git a/src/settings-code.ts b/src/settings/settings-code.ts similarity index 99% rename from src/settings-code.ts rename to src/settings/settings-code.ts index 1aa17a0..3285d14 100644 --- a/src/settings-code.ts +++ b/src/settings/settings-code.ts @@ -1,4 +1,4 @@ -import { isValidEncoder } from "./encoders"; +import { isValidEncoder } from "../core/encoders"; import type { JobSettings, EncoderQuality, @@ -19,8 +19,8 @@ import type { SubtitleFormatPriority, AudioCodecPriority, CropMode, -} from "./types"; -import { vsRegistry } from "./vs-filters"; +} from "../core/types"; +import { vsRegistry } from "../video/vs-filters"; export const SETTINGS_CODE_FORMAT = 1; export const SETTINGS_CODE_PREFIX = `RE${SETTINGS_CODE_FORMAT}`; diff --git a/src/ass-classifier.ts b/src/subtitles/ass-classifier.ts similarity index 100% rename from src/ass-classifier.ts rename to src/subtitles/ass-classifier.ts diff --git a/src/ass-edit.ts b/src/subtitles/ass-edit.ts similarity index 100% rename from src/ass-edit.ts rename to src/subtitles/ass-edit.ts diff --git a/src/ass-style.ts b/src/subtitles/ass-style.ts similarity index 99% rename from src/ass-style.ts rename to src/subtitles/ass-style.ts index ab373f5..b00b3d9 100644 --- a/src/ass-style.ts +++ b/src/subtitles/ass-style.ts @@ -1,6 +1,6 @@ -import pkg from "../package.json"; +import pkg from "../../package.json"; import { dialogueStyleNames } from "./ass-classifier"; -import type { SubtitleStyle } from "./types"; +import type { SubtitleStyle } from "../core/types"; export const ASS_SIGNATURE_KEY = "RabbitEncoder"; const TOOL_VERSION: string = pkg.version; diff --git a/src/letter-signs.ts b/src/subtitles/letter-signs.ts similarity index 100% rename from src/letter-signs.ts rename to src/subtitles/letter-signs.ts diff --git a/src/srt-edit.ts b/src/subtitles/srt-edit.ts similarity index 100% rename from src/srt-edit.ts rename to src/subtitles/srt-edit.ts diff --git a/src/subtitle-style.ts b/src/subtitles/subtitle-style.ts similarity index 93% rename from src/subtitle-style.ts rename to src/subtitles/subtitle-style.ts index bc3dbe9..43fa041 100644 --- a/src/subtitle-style.ts +++ b/src/subtitles/subtitle-style.ts @@ -1,5 +1,5 @@ -import type { SubtitleStyle } from "./types"; -import { faceCandidateKeys, type ScriptName } from "./script-detect"; +import type { SubtitleStyle } from "../core/types"; +import { faceCandidateKeys, type ScriptName } from "../fonts/script-detect"; /** Subtitle appearance, minus the family name (which is always face-derived). */ export type StyleAppearance = Omit; diff --git a/src/tracks.ts b/src/tracks/tracks.ts similarity index 99% rename from src/tracks.ts rename to src/tracks/tracks.ts index d74c671..0a88af8 100644 --- a/src/tracks.ts +++ b/src/tracks/tracks.ts @@ -12,14 +12,14 @@ import type { SubtitlePreviewTrack, SubtitleSourcePriority, SubtitleStreamInfo, -} from "./types"; -import { run } from "./process"; -import { Logger } from "./logger"; +} from "../core/types"; +import { run } from "../core/process"; +import { Logger } from "../core/logger"; import { join } from "path"; import { readFileSync, unlinkSync, existsSync, statSync } from "fs"; -import { classifyAssLines } from "./ass-classifier"; -import { LANG_ALIASES } from "./naming"; -import { getOpusBitrateForLayout, normalizeLayout } from "./probe"; +import { classifyAssLines } from "../subtitles/ass-classifier"; +import { LANG_ALIASES } from "../core/naming"; +import { getOpusBitrateForLayout, normalizeLayout } from "../pipeline/probe"; const BARE_SCORE_THRESHOLD = 3; const MIN_LINES_FOR_LANG_DETECTION = 5; diff --git a/src/deepseek.ts b/src/translate/deepseek.ts similarity index 99% rename from src/deepseek.ts rename to src/translate/deepseek.ts index b3a3dd3..9506d88 100644 --- a/src/deepseek.ts +++ b/src/translate/deepseek.ts @@ -1,4 +1,4 @@ -import { Logger } from "./logger"; +import { Logger } from "../core/logger"; const DEEPSEEK_BASE = "https://api.deepseek.com"; const DEFAULT_TIMEOUT_MS = 300_000; diff --git a/src/ollama-generic.ts b/src/translate/generic.ts similarity index 99% rename from src/ollama-generic.ts rename to src/translate/generic.ts index d9f9d8c..613e0f4 100644 --- a/src/ollama-generic.ts +++ b/src/translate/generic.ts @@ -1,5 +1,5 @@ import { deepseekChat } from "./deepseek"; -import { Logger } from "./logger"; +import { Logger } from "../core/logger"; import type { TranslateLang } from "./translate-languages"; /** diff --git a/src/ollama.ts b/src/translate/ollama.ts similarity index 99% rename from src/ollama.ts rename to src/translate/ollama.ts index a515adf..b6197b5 100644 --- a/src/ollama.ts +++ b/src/translate/ollama.ts @@ -1,4 +1,4 @@ -import { Logger } from "./logger"; +import { Logger } from "../core/logger"; import type { TranslateLang } from "./translate-languages"; /** diff --git a/src/subtitle-translate.ts b/src/translate/subtitle-translate.ts similarity index 98% rename from src/subtitle-translate.ts rename to src/translate/subtitle-translate.ts index b9d788d..1233f71 100644 --- a/src/subtitle-translate.ts +++ b/src/translate/subtitle-translate.ts @@ -1,11 +1,11 @@ -import { Logger } from "./logger"; -import { parseAssEvents, splitAssText, joinAssText, buildTranslatedAss, type AssEventLine } from "./ass-edit"; -import { parseSrt, buildSrt } from "./srt-edit"; +import { Logger } from "../core/logger"; +import { parseAssEvents, splitAssText, joinAssText, buildTranslatedAss, type AssEventLine } from "../subtitles/ass-edit"; +import { parseSrt, buildSrt } from "../subtitles/srt-edit"; import { translateBatch } from "./ollama"; -import { resolveTranslateLang, normalizeTag, type TranslateLang } from "./translate-languages"; -import { translateBatchGeneric, type GenericOptions, type TranslateItem } from "./ollama-generic"; -import { createSemaphore, type Semaphore } from "./concurrency"; -import { letterSignReplacementTexts, reconstructLetterSigns } from "./letter-signs"; +import { resolveTranslateLang, normalizeTag, type TranslateLang } from "../translate/translate-languages"; +import { translateBatchGeneric, type GenericOptions, type TranslateItem } from "./generic"; +import { createSemaphore, type Semaphore } from "../core/concurrency"; +import { letterSignReplacementTexts, reconstructLetterSigns } from "../subtitles/letter-signs"; /** * True when a sign/song's visible text is a single character. Animated diff --git a/src/translate-languages.ts b/src/translate/translate-languages.ts similarity index 99% rename from src/translate-languages.ts rename to src/translate/translate-languages.ts index 7d983c3..d6f3c77 100644 --- a/src/translate-languages.ts +++ b/src/translate/translate-languages.ts @@ -1,4 +1,4 @@ -import { LANG_ALIASES } from "./naming"; +import { LANG_ALIASES } from "../core/naming"; /** * A language TranslateGemma can translate to/from, described the way its prompt diff --git a/src/translate-provider.ts b/src/translate/translate-provider.ts similarity index 100% rename from src/translate-provider.ts rename to src/translate/translate-provider.ts diff --git a/src/translate-step.ts b/src/translate/translate-step.ts similarity index 97% rename from src/translate-step.ts rename to src/translate/translate-step.ts index c63dd8c..a039cd7 100644 --- a/src/translate-step.ts +++ b/src/translate/translate-step.ts @@ -1,17 +1,17 @@ import { join } from "path"; import { readFileSync, writeFileSync, existsSync } from "fs"; -import { Logger } from "./logger"; -import { run } from "./process"; -import type { JobSettings, SubtitleStreamInfo, SubtitleStyle } from "./types"; -import { detectSubtitleTrackType, buildSubtitleTrackName, isTextSubtitleCodec } from "./tracks"; -import { dialogueStyleNames } from "./ass-classifier"; -import { styleSrtAss, restyleAssDialogueFont } from "./ass-style"; +import { Logger } from "../core/logger"; +import { run } from "../core/process"; +import type { JobSettings, SubtitleStreamInfo, SubtitleStyle } from "../core/types"; +import { detectSubtitleTrackType, buildSubtitleTrackName, isTextSubtitleCodec } from "../tracks/tracks"; +import { dialogueStyleNames } from "../subtitles/ass-classifier"; +import { styleSrtAss, restyleAssDialogueFont } from "../subtitles/ass-style"; import { checkOllama } from "./ollama"; import { planTargetLanguages, translateSubtitleContent, type KeptSubDescriptor } from "./subtitle-translate"; -import { createSemaphore } from "./concurrency"; +import { createSemaphore } from "../core/concurrency"; import { resolveTranslateStrategy } from "./translate-provider"; import { checkDeepseek } from "./deepseek"; -import type { GenericOptions } from "./ollama-generic"; +import type { GenericOptions } from "./generic"; /** * A finished, on-disk translated subtitle ready to hand to mkvmerge. Shaped to diff --git a/src/auto-denoise.ts b/src/video/auto-denoise.ts similarity index 99% rename from src/auto-denoise.ts rename to src/video/auto-denoise.ts index 66f87e8..14ffa04 100644 --- a/src/auto-denoise.ts +++ b/src/video/auto-denoise.ts @@ -1,9 +1,9 @@ import { join } from "path"; import { readFileSync, existsSync, unlinkSync, mkdirSync, writeFileSync, rmSync } from "fs"; -import { Logger } from "./logger"; -import { CancelledError, run } from "./process"; +import { Logger } from "../core/logger"; +import { CancelledError, run } from "../core/process"; import { formatNlmeansParams, isOpenClAvailable, isVulkanAvailable, defaultDeviceFor } from "./filters"; -import type { AutoDenoiseThresholds, DenoiseBackend, GpuBackend, NlmeansLevelParams } from "./types"; +import type { AutoDenoiseThresholds, DenoiseBackend, GpuBackend, NlmeansLevelParams } from "../core/types"; export const DEFAULT_AUTO_THRESHOLDS: AutoDenoiseThresholds = { light: 0.5, diff --git a/src/benchmark.ts b/src/video/benchmark.ts similarity index 98% rename from src/benchmark.ts rename to src/video/benchmark.ts index fc8a7c9..4b5d17d 100644 --- a/src/benchmark.ts +++ b/src/video/benchmark.ts @@ -1,10 +1,10 @@ -import { Logger } from "./logger"; +import { Logger } from "../core/logger"; import { isOpenClAvailable, isVulkanAvailable, DEFAULT_NLMEANS_PARAMS, formatNlmeansParams, defaultDeviceFor } from "./filters"; -import { getAllJobs } from "./store"; -import { getCpuName } from "./system"; +import { getAllJobs } from "../queue/store"; +import { getCpuName } from "../core/system"; import { listOpenClDevices, type OpenClDevice } from "./opencl"; import { listVulkanDevices, type VulkanDevice } from "./vulkan"; -import type { DenoiseBackend } from "./types"; +import type { DenoiseBackend } from "../core/types"; const DURATION = 10; const SIZE = "1920x1080"; diff --git a/src/color-metadata.ts b/src/video/color-metadata.ts similarity index 97% rename from src/color-metadata.ts rename to src/video/color-metadata.ts index 0be1ef0..e633c9f 100644 --- a/src/color-metadata.ts +++ b/src/video/color-metadata.ts @@ -1,5 +1,5 @@ -import { run } from "./process"; -import type { ProbeResult } from "./types"; +import { run } from "../core/process"; +import type { ProbeResult } from "../core/types"; export function svtColorParamsFromProbe(probe: ProbeResult): string { const primMap: Record = { "BT.709": 1, "BT.2020": 9, "BT.601 NTSC": 6, "BT.601 PAL": 5 }; diff --git a/src/filters.ts b/src/video/filters.ts similarity index 99% rename from src/filters.ts rename to src/video/filters.ts index ef6ea70..02d9741 100644 --- a/src/filters.ts +++ b/src/video/filters.ts @@ -8,10 +8,10 @@ import type { GradfunParams, GradfunLevelParams, CropMode, -} from "./types"; -import { Logger } from "./logger"; +} from "../core/types"; +import { Logger } from "../core/logger"; import { buildAutoDenoiseFilter, type DenoisePlan } from "./auto-denoise"; -import { run } from "./process"; +import { run } from "../core/process"; /** Default nlmeans parameters per level. Used when env vars / settings don't override. */ export const DEFAULT_NLMEANS_PARAMS: NlmeansLevelParams = { diff --git a/src/opencl.ts b/src/video/opencl.ts similarity index 97% rename from src/opencl.ts rename to src/video/opencl.ts index d913592..ccae3db 100644 --- a/src/opencl.ts +++ b/src/video/opencl.ts @@ -1,4 +1,4 @@ -import { Logger } from "./logger"; +import { Logger } from "../core/logger"; export interface OpenClDevice { /** Spec consumed by FFmpeg's -init_hw_device opencl=gpu:X.Y */ diff --git a/src/vs-filters.ts b/src/video/vs-filters.ts similarity index 98% rename from src/vs-filters.ts rename to src/video/vs-filters.ts index 425cca0..ee2bf2b 100644 --- a/src/vs-filters.ts +++ b/src/video/vs-filters.ts @@ -1,9 +1,9 @@ -import { existsSync, readFileSync, readdirSync, statSync } from "fs"; -import { join, parse as parsePath, basename } from "path"; -import { Logger } from "./logger"; -import { CancelledError, computeFps, fmtFrames } from "./process"; +import { existsSync, readFileSync, readdirSync } from "fs"; +import { join, parse as parsePath } from "path"; +import { Logger } from "../core/logger"; +import { CancelledError, computeFps, fmtFrames } from "../core/process"; import { FFV1_ENCODE_ARGS } from "./auto-denoise"; -import type { VsFilterEntry, VsParamSpec, VsParamType, VsParamValue, VsPresetManifest, VsPresetSource } from "./types"; +import type { VsFilterEntry, VsParamSpec, VsParamType, VsParamValue, VsPresetManifest, VsPresetSource } from "../core/types"; const STOCK_DIR_DEFAULT = "/app/vapoursynth/presets"; const USER_DIR_DEFAULT = "/config/vapoursynth/presets"; diff --git a/src/vulkan.ts b/src/video/vulkan.ts similarity index 98% rename from src/vulkan.ts rename to src/video/vulkan.ts index d20349b..7146c7d 100644 --- a/src/vulkan.ts +++ b/src/video/vulkan.ts @@ -1,4 +1,4 @@ -import { Logger } from "./logger"; +import { Logger } from "../core/logger"; export interface VulkanDevice { /** Spec consumed by FFmpeg's -init_hw_device vulkan=gpu:N (N is the device index). */ diff --git a/tests/fixtures/ass.ts b/tests/fixtures/ass.ts index d2ad4a7..f55cab0 100644 --- a/tests/fixtures/ass.ts +++ b/tests/fixtures/ass.ts @@ -1,4 +1,4 @@ -import type { SubtitleStyle } from "../../src/types"; +import type { SubtitleStyle } from "../../src/core/types"; /** * A known SubtitleStyle authored in 1080p space, used across styling tests. diff --git a/tests/script-detect.test.ts b/tests/fonts/script-detect.test.ts similarity index 96% rename from tests/script-detect.test.ts rename to tests/fonts/script-detect.test.ts index e92d788..6a2b61b 100644 --- a/tests/script-detect.test.ts +++ b/tests/fonts/script-detect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { detectScript, extractDialogueText } from "../src/script-detect"; +import { detectScript, extractDialogueText } from "../../src/fonts/script-detect"; describe("detectScript", () => { it("identifies Latin / Cyrillic / Greek", () => { diff --git a/tests/settings-code.test.ts b/tests/settings/settings-code.test.ts similarity index 80% rename from tests/settings-code.test.ts rename to tests/settings/settings-code.test.ts index a9d71cd..3bc0081 100644 --- a/tests/settings-code.test.ts +++ b/tests/settings/settings-code.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getDefaultJobSettings } from "../src/config"; -import type { JobSettings } from "../src/types"; -import { encodeSettingsCode, decodeSettingsCode } from "../src/settings-code"; +import { getDefaultJobSettings } from "../../src/core/config"; +import type { JobSettings } from "../../src/core/types"; +import { encodeSettingsCode, decodeSettingsCode } from "../../src/settings/settings-code"; describe("settings code — font/style not carried (RE1 compat)", () => { it("never emits font/style/group fields", () => { diff --git a/tests/ass-classifier.test.ts b/tests/subtitles/ass-classifier.test.ts similarity index 97% rename from tests/ass-classifier.test.ts rename to tests/subtitles/ass-classifier.test.ts index af4edaa..696117e 100644 --- a/tests/ass-classifier.test.ts +++ b/tests/subtitles/ass-classifier.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { classifyAssLines, dialogueStyleNames, extractUsedFonts, normalizeFontName } from "../src/ass-classifier"; -import { buildAss, DEFAULT_STYLE_LINE, SIGN_STYLE_LINE } from "./fixtures/ass"; +import { classifyAssLines, dialogueStyleNames, extractUsedFonts, normalizeFontName } from "../../src/subtitles/ass-classifier"; +import { buildAss, DEFAULT_STYLE_LINE, SIGN_STYLE_LINE } from "../fixtures/ass"; const OP_STYLE_LINE = "Style: OP,Arial,40,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,1,0,8,40,40,40,1"; const UNUSED_STYLE_LINE = "Style: Unused,Wingdings,40,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,1,0,5,40,40,40,1"; diff --git a/tests/ass-style.test.ts b/tests/subtitles/ass-style.test.ts similarity index 98% rename from tests/ass-style.test.ts rename to tests/subtitles/ass-style.test.ts index 3ac5663..5e9a293 100644 --- a/tests/ass-style.test.ts +++ b/tests/subtitles/ass-style.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import pkg from "../package.json"; -import { restyleAssDialogueFont, stampSignature, styleSrtAss } from "../src/ass-style"; -import { ass1080, ass4k, ass4kWithSign, ass720, assNoPlayRes, buildAss, getScriptInfo, getStyle, sampleStyle, SIGN_STYLE_LINE } from "./fixtures/ass"; +import pkg from "../../package.json"; +import { restyleAssDialogueFont, stampSignature, styleSrtAss } from "../../src/subtitles/ass-style"; +import { ass1080, ass4k, ass4kWithSign, ass720, assNoPlayRes, buildAss, getScriptInfo, getStyle, sampleStyle, SIGN_STYLE_LINE } from "../fixtures/ass"; describe("styleSrtAss (SRT→ASS conversion path)", () => { it("pins PlayRes to 1920×1080 and enables scaled borders", () => { diff --git a/tests/letter-signs.test.ts b/tests/subtitles/letter-signs.test.ts similarity index 98% rename from tests/letter-signs.test.ts rename to tests/subtitles/letter-signs.test.ts index c9c2117..ffba746 100644 --- a/tests/letter-signs.test.ts +++ b/tests/subtitles/letter-signs.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { parseAssEvents } from "../src/ass-edit"; -import { reconstructLetterSigns, letterSignReplacementTexts, type ReconstructedLetterSign } from "../src/letter-signs"; -import { buildAss, DEFAULT_STYLE_LINE, SIGN_STYLE_LINE } from "./fixtures/ass"; -import { sampleEvents, SAMPLE_GLYPHS, HURTS_STYLE_LINE } from "./fixtures/letter-sign-sample"; +import { parseAssEvents } from "../../src/subtitles/ass-edit"; +import { reconstructLetterSigns, letterSignReplacementTexts } from "../../src/subtitles/letter-signs"; +import { buildAss, DEFAULT_STYLE_LINE, SIGN_STYLE_LINE } from "../fixtures/ass"; +import { sampleEvents, SAMPLE_GLYPHS, HURTS_STYLE_LINE } from "../fixtures/letter-sign-sample"; const isDialogue = (s: string) => s === "Default"; diff --git a/tests/noise-signs.test.ts b/tests/subtitles/noise-signs.test.ts similarity index 98% rename from tests/noise-signs.test.ts rename to tests/subtitles/noise-signs.test.ts index 1c63823..77a35b8 100644 --- a/tests/noise-signs.test.ts +++ b/tests/subtitles/noise-signs.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, afterEach } from "bun:test"; -import { isNoiseSign, findFrameChurnEvents, translateSubtitleContent, type TranslateContentOptions } from "../src/subtitle-translate"; -import { parseAssEvents } from "../src/ass-edit"; -import { buildAss, DEFAULT_STYLE_LINE, SIGN_STYLE_LINE } from "./fixtures/ass"; +import { isNoiseSign, findFrameChurnEvents, translateSubtitleContent, type TranslateContentOptions } from "../../src/translate/subtitle-translate"; +import { parseAssEvents } from "../../src/subtitles/ass-edit"; +import { buildAss, DEFAULT_STYLE_LINE, SIGN_STYLE_LINE } from "../fixtures/ass"; const EN = { name: "English", code: "en" }; const SL = { name: "Slovenian", code: "sl" }; diff --git a/tests/sign-clustering.test.ts b/tests/subtitles/sign-clustering.test.ts similarity index 97% rename from tests/sign-clustering.test.ts rename to tests/subtitles/sign-clustering.test.ts index 6bf3c3b..70ec34a 100644 --- a/tests/sign-clustering.test.ts +++ b/tests/subtitles/sign-clustering.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, afterEach } from "bun:test"; -import { scrambleDistance, clusterSignEvents, translateSubtitleContent, type SignEventInput, type TranslateContentOptions } from "../src/subtitle-translate"; -import { buildAss, DEFAULT_STYLE_LINE, SIGN_STYLE_LINE } from "./fixtures/ass"; +import { + scrambleDistance, + clusterSignEvents, + translateSubtitleContent, + type SignEventInput, + type TranslateContentOptions, +} from "../../src/translate/subtitle-translate"; +import { buildAss, DEFAULT_STYLE_LINE, SIGN_STYLE_LINE } from "../fixtures/ass"; const EN = { name: "English", code: "en" }; const SL = { name: "Slovenian", code: "sl" }; diff --git a/tests/subtitle-edit.test.ts b/tests/subtitles/subtitle-edit.test.ts similarity index 96% rename from tests/subtitle-edit.test.ts rename to tests/subtitles/subtitle-edit.test.ts index fc625ce..0164769 100644 --- a/tests/subtitle-edit.test.ts +++ b/tests/subtitles/subtitle-edit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { parseSrt, buildSrt } from "../src/srt-edit"; -import { parseAssEvents, splitAssText, joinAssText, buildTranslatedAss, assTimeToMs } from "../src/ass-edit"; -import { resolveTranslateLang, isTranslatable } from "../src/translate-languages"; +import { parseSrt, buildSrt } from "../../src/subtitles/srt-edit"; +import { parseAssEvents, splitAssText, joinAssText, buildTranslatedAss, assTimeToMs } from "../../src/subtitles/ass-edit"; +import { resolveTranslateLang, isTranslatable } from "../../src/translate/translate-languages"; describe("srt-edit", () => { const SRT = ["1", "00:00:01,000 --> 00:00:03,500", "Hello world", "", "2", "00:00:04,000 --> 00:00:06,000", "Two", "lines", ""].join("\n"); diff --git a/tests/subtitle-style.test.ts b/tests/subtitles/subtitle-style.test.ts similarity index 95% rename from tests/subtitle-style.test.ts rename to tests/subtitles/subtitle-style.test.ts index e4cb9c5..2e0838b 100644 --- a/tests/subtitle-style.test.ts +++ b/tests/subtitles/subtitle-style.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { resolveStyleAppearance, DEFAULT_STYLE_APPEARANCE, type GroupStyleConfig } from "../src/subtitle-style"; -import { fontAttachmentFileName } from "../src/font-instance"; +import { resolveStyleAppearance, DEFAULT_STYLE_APPEARANCE, type GroupStyleConfig } from "../../src/subtitles/subtitle-style"; +import { fontAttachmentFileName } from "../../src/fonts/font-instance"; describe("resolveStyleAppearance — precedence", () => { const group: GroupStyleConfig = { diff --git a/tests/ordering.test.ts b/tests/tracks/ordering.test.ts similarity index 95% rename from tests/ordering.test.ts rename to tests/tracks/ordering.test.ts index 25324c8..4ac1b9c 100644 --- a/tests/ordering.test.ts +++ b/tests/tracks/ordering.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { orderOutputSubtitles, type NativeEmitItem, type TranslatedEmitItem } from "../src/translate-step"; -import { buildSubtitleTrackName, sortSubtitleStreams } from "../src/tracks"; -import type { SubtitleStreamInfo } from "../src/types"; +import { orderOutputSubtitles, type NativeEmitItem, type TranslatedEmitItem } from "../../src/translate/translate-step"; +import { buildSubtitleTrackName, sortSubtitleStreams } from "../../src/tracks/tracks"; +import type { SubtitleStreamInfo } from "../../src/core/types"; const emit = (language: string, trackName: string, file: string) => ({ language, diff --git a/tests/tracks-group.test.ts b/tests/tracks/tracks-group.test.ts similarity index 99% rename from tests/tracks-group.test.ts rename to tests/tracks/tracks-group.test.ts index ace126c..2e00fea 100644 --- a/tests/tracks-group.test.ts +++ b/tests/tracks/tracks-group.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { buildSubtitleTrackName, extractGroupFromTitle } from "../src/tracks"; +import { buildSubtitleTrackName, extractGroupFromTitle } from "../../src/tracks/tracks"; describe("extractGroupFromTitle — pipe-separated", () => { it("takes the group after the pipe, before credits", () => { diff --git a/tests/chunks.test.ts b/tests/translate/chunks.test.ts similarity index 97% rename from tests/chunks.test.ts rename to tests/translate/chunks.test.ts index 9e0407c..e1543d8 100644 --- a/tests/chunks.test.ts +++ b/tests/translate/chunks.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { planChunks } from "../src/subtitle-translate"; +import { planChunks } from "../../src/translate/subtitle-translate"; describe("planChunks", () => { it("returns a single chunk when count <= batchSize", () => { diff --git a/tests/ollama-generic.test.ts b/tests/translate/generic.test.ts similarity index 98% rename from tests/ollama-generic.test.ts rename to tests/translate/generic.test.ts index d9a279b..f71f57b 100644 --- a/tests/ollama-generic.test.ts +++ b/tests/translate/generic.test.ts @@ -8,7 +8,7 @@ import { type TranslateItem, type GenericOptions, repairJsonEscapes, -} from "../src/ollama-generic"; +} from "../../src/translate/generic"; const EN = { name: "English", code: "en" }; const SL = { name: "Slovenian", code: "sl" }; diff --git a/tests/ollama.test.ts b/tests/translate/ollama.test.ts similarity index 97% rename from tests/ollama.test.ts rename to tests/translate/ollama.test.ts index b4e0acd..c89fedd 100644 --- a/tests/ollama.test.ts +++ b/tests/translate/ollama.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it, afterEach } from "bun:test"; -import { translateBatch, translateOne, buildTranslatePrompt } from "../src/ollama"; -import { planTargetLanguages, type KeptSubDescriptor } from "../src/subtitle-translate"; -import type { OllamaOptions } from "../src/ollama"; -import { repairJsonEscapes } from "../src/ollama-generic"; +import { translateBatch, translateOne, buildTranslatePrompt } from "../../src/translate/ollama"; +import { planTargetLanguages, type KeptSubDescriptor } from "../../src/translate/subtitle-translate"; +import type { OllamaOptions } from "../../src/translate/ollama"; const EN = { name: "English", code: "en" }; const SL = { name: "Slovenian", code: "sl" }; diff --git a/tests/translate-dedup.test.ts b/tests/translate/translate-dedup.test.ts similarity index 98% rename from tests/translate-dedup.test.ts rename to tests/translate/translate-dedup.test.ts index 856e32d..3aded19 100644 --- a/tests/translate-dedup.test.ts +++ b/tests/translate/translate-dedup.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, afterEach } from "bun:test"; -import { translateSubtitleContent, type TranslateContentOptions } from "../src/subtitle-translate"; -import { buildAss, DEFAULT_STYLE_LINE, SIGN_STYLE_LINE } from "./fixtures/ass"; +import { translateSubtitleContent, type TranslateContentOptions } from "../../src/translate/subtitle-translate"; +import { buildAss, DEFAULT_STYLE_LINE, SIGN_STYLE_LINE } from "../fixtures/ass"; const EN = { name: "English", code: "en" }; const SL = { name: "Slovenian", code: "sl" }; diff --git a/tests/translate-step.test.ts b/tests/translate/translate-step.test.ts similarity index 92% rename from tests/translate-step.test.ts rename to tests/translate/translate-step.test.ts index ae00fb6..8bb54d5 100644 --- a/tests/translate-step.test.ts +++ b/tests/translate/translate-step.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { computeTranslatedFlagArgs, resolveOutputFormat, buildKeptDescriptors } from "../src/translate-step"; -import type { SubtitleStreamInfo } from "../src/types"; +import { computeTranslatedFlagArgs, resolveOutputFormat, buildKeptDescriptors } from "../../src/translate/translate-step"; +import type { SubtitleStreamInfo } from "../../src/core/types"; describe("translate-step helpers", () => { it("marks a translated track default for its new language", () => { From cc7a29b5c0a8a210895cdf387391563f1ae65474 Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Wed, 8 Jul 2026 11:38:26 +0200 Subject: [PATCH 11/13] Improve calculation of estimated final file size in preview encoder --- public/features/preview.ts | 3 +-- src/pipeline/preview-encoder.ts | 36 +++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/public/features/preview.ts b/public/features/preview.ts index bc9a322..ae19eb2 100644 --- a/public/features/preview.ts +++ b/public/features/preview.ts @@ -102,9 +102,8 @@ export function renderPreviewSummary(samples: PreviewSample[]): void { const minProjected = Math.min(...projections); const maxProjected = Math.max(...projections); - const totalEncodedBytes = samples.reduce((a, s) => a + (s.encodedSizeBytes || 0), 0); const totalSampledSec = samples.reduce((a, s) => a + (s.windowSeconds || 0), 0); - const avgBitrateKbps = totalSampledSec > 0 ? (totalEncodedBytes * 8) / 1000 / totalSampledSec : 0; + const avgBitrateKbps = count > 0 ? samples.reduce((a, s) => a + (s.encodedBitrateKbps || 0), 0) / count : 0; el.style.display = ""; el.innerHTML = ` diff --git a/src/pipeline/preview-encoder.ts b/src/pipeline/preview-encoder.ts index f3d9b7f..4198ec0 100644 --- a/src/pipeline/preview-encoder.ts +++ b/src/pipeline/preview-encoder.ts @@ -89,6 +89,33 @@ function escapeSubtitlesFilterPath(p: string): string { return p.replace(/\\/g, "\\\\").replace(/:/g, "\\:"); } +/** + * Sum of attachment sizes (fonts etc.) inside an MKV. These are per-file + * constants, so they must be excluded from linear duration scaling. + */ +async function probeAttachmentBytes(path: string, signal: AbortSignal): Promise { + try { + const res = await run(["mkvmerge", "-J", path], { signal }); + if (res.code !== 0 && res.code !== 1) return 0; + const attachments: Array<{ size?: number }> = JSON.parse(res.stdout)?.attachments ?? []; + return attachments.reduce((sum, a) => sum + (Number.isFinite(a.size) ? a.size! : 0), 0); + } catch { + return 0; + } +} + +/** Actual container duration of the encoded clip (audio packet cuts can drift from the requested window). */ +async function probeClipDurationSec(path: string, signal: AbortSignal): Promise { + try { + const res = await run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path], { signal }); + if (res.code !== 0) return null; + const d = parseFloat(res.stdout.trim()); + return Number.isFinite(d) && d > 0 ? d : null; + } catch { + return null; + } +} + async function captureFrame( inputPath: string, outPath: string, @@ -308,7 +335,12 @@ export async function runPreviewEncode(args: RunPreviewArgs): Promise 0 ? await probeAttachmentBytes(encodedClip, signal) : 0; + const clipDurationSec = sizeBytes > 0 ? ((await probeClipDurationSec(encodedClip, signal)) ?? opts.windowSeconds) : opts.windowSeconds; + const streamBytes = Math.max(0, sizeBytes - attachmentBytes); + const projectedTotalBytes = Math.round(attachmentBytes + (streamBytes / clipDurationSec) * probe.duration); + completed.push({ index: i, timestampSec: stamps[i]!, @@ -317,7 +349,7 @@ export async function runPreviewEncode(args: RunPreviewArgs): Promise Date: Wed, 8 Jul 2026 11:54:58 +0200 Subject: [PATCH 12/13] Bump version number --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f99bddb..f2dc82f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rabbit-encoder", - "version": "7.4.0", + "version": "8.0.0", "type": "module", "scripts": { "dev": "bun run --watch src/index.ts", From 4e0717b736aef4b3ba6947b7cdc84982ecb633d6 Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Wed, 8 Jul 2026 12:45:39 +0200 Subject: [PATCH 13/13] Improve subtitle translation input for target languages --- public/features/settings-controls.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/public/features/settings-controls.ts b/public/features/settings-controls.ts index 863a97c..5d5377b 100644 --- a/public/features/settings-controls.ts +++ b/public/features/settings-controls.ts @@ -523,15 +523,21 @@ export function renderLanguageFilterInput(container: HTMLElement, value: string[ export function renderTranslationLanguagesInput(container: HTMLElement, value: string[], onChange: (value: string[]) => void): void { container.innerHTML = ""; + const label = document.createElement("label"); + label.textContent = "Target languages"; + container.appendChild(label); + const input = document.createElement("input"); input.type = "text"; input.className = "lang-filter-input"; - input.placeholder = "jpn, eng"; + input.placeholder = "eng, slv"; input.value = (value || []).join(", "); const hint = document.createElement("div"); hint.className = "lang-filter-hint"; - hint.textContent = "Comma-separated ISO codes."; + hint.textContent = + "Comma-separated ISO codes. Every listed language should end up with a full subtitle: " + + "if one is missing, it's translated with the LLM from an existing full subtitle."; input.oninput = () => onChange(