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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "rabbit-encoder",
"version": "7.3.1",
"version": "8.0.0",
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
Expand Down
19 changes: 19 additions & 0 deletions public/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,3 +393,22 @@ export async function fetchVsDefaultEntry(presetId: string): Promise<VsFilterEnt
const res = await authFetch(`${API}/api/vs-presets/${encodeURIComponent(presetId)}/default-entry`);
return res.json();
}

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(opts),
});
return await res.json();
} catch (err: any) {
return { ok: false, error: err?.message || "Request failed" };
}
}
13 changes: 12 additions & 1 deletion public/config/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import type {
EncoderSpeed,
StyleAppearance,
SubtitleProcessingMode,
SubtitleStyle,
VideoEncodeMode,
} from "../types";
import type { PipelinePreset } from "../ui/models";
Expand Down Expand Up @@ -87,6 +86,18 @@ export const PIPELINE_PRESET_HELP: Record<PipelinePreset, string> = {
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<TranslateProviderOption, string> = {
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"];

export const DEFAULT_STYLE_APPEARANCE: StyleAppearance = {
fontSize: 80,
primaryColour: "&H00FFFFFF",
Expand Down
3 changes: 1 addition & 2 deletions public/features/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand Down
81 changes: 80 additions & 1 deletion public/features/settings-controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,37 @@ 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 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 = "eng, slv";
input.value = (value || []).join(", ");

const hint = document.createElement("div");
hint.className = "lang-filter-hint";
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(
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");
Expand Down Expand Up @@ -840,7 +871,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");
Expand All @@ -857,3 +888,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);
}
120 changes: 120 additions & 0 deletions public/features/settings-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
AUDIO_ENCODE_OPTIONS,
CROP_OPTIONS,
DEBAND_LEVELS,
DEEPSEEK_MODELS,
DEFAULT_AUTO_THRESHOLDS,
DEFAULT_GRADFUN_PARAMS,
DEFAULT_NLMEANS_PARAMS,
Expand All @@ -17,6 +18,10 @@ import {
SUBTITLE_FORMAT_PRIORITY_OPTIONS,
SUBTITLE_PROCESSING_OPTIONS,
SUBTITLE_SOURCE_PRIORITY_OPTIONS,
TRANSLATE_MODEL_OPTIONS,
TRANSLATE_PROVIDERS,
TRANSLATE_PROVIDER_LABELS,
TRANSLATE_STRATEGIES,
VIDEO_ENCODE_OPTIONS,
} from "../config/options";
import {
Expand All @@ -34,15 +39,20 @@ import {
renderLanguagePriorityInput,
renderNoPhaseInvToggle,
renderNumberControl,
renderPasswordControl,
renderRadioPills,
renderRemoveCommentaryAudioToggle,
renderSelectControl,
renderSkipBoostingToggle,
renderSubtitleConfidenceControl,
renderSubtitleLangDetectControl,
renderSubtitleStyleTargets,
renderTextControl,
renderTranslationLanguagesInput,
wireEncoderControls,
} from "./settings-controls";
import { byId } from "../shared/dom";
import { testTranslateConnection } from "../api/client";

export type SettingsFormPrefix = "default" | "job";

Expand Down Expand Up @@ -311,4 +321,114 @@ 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;
});

// 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;
};

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);

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;
});
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;
});
}
};

// 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;
});

renderNumberControl(el("translate-batch"), "Dialogs per request", settings.translateBatchSize, { min: 1, max: 1000, step: 1 }, (v) => {
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;
});

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 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}"`;
testResult.classList.add("ok");
} else {
testResult.textContent = `✗ ${r.error}`;
testResult.classList.add("error");
}
});
}
50 changes: 50 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,31 @@ <h2>Default Settings</h2>
</div>
</div>
</details>
<details class="settings-subsection">
<summary>Subtitle translation</summary>
<div class="settings-subsection-body">
<div class="setting-group">
<div id="default-translate-enabled" class="toggle-group"></div>
</div>
<div class="setting-group">
<label>Provider</label>
<div class="radio-group" id="default-translate-provider"></div>
<div id="default-translate-provider-settings"></div>
</div>
<div class="setting-group">
<div id="default-translate-targets" class="toggle-group"></div>
<div id="default-translate-batch" class="toggle-group"></div>
<div id="default-translate-timeout" class="toggle-group"></div>
<div id="default-translate-concurrency" class="toggle-group"></div>
<div id="default-translate-signs" class="toggle-group"></div>
</div>
<div class="setting-group">
<button class="btn btn-ghost" id="default-translate-test">Test connection</button>
<br /><br />
<span id="default-translate-test-result"></span>
</div>
</div>
</details>
<div class="setting-group">
<label>Subtitle language priority</label>
<div id="default-subtitle-lang-priority"></div>
Expand Down Expand Up @@ -512,6 +537,31 @@ <h2 id="job-modal-title">Job Settings</h2>
</div>
</div>
</details>
<details class="settings-subsection">
<summary>Subtitle translation</summary>
<div class="settings-subsection-body">
<div class="setting-group">
<div id="job-translate-enabled" class="toggle-group"></div>
</div>
<div class="setting-group">
<label>Provider</label>
<div class="radio-group" id="job-translate-provider"></div>
<div id="job-translate-provider-settings"></div>
</div>
<div class="setting-group">
<div id="job-translate-targets" class="toggle-group"></div>
<div id="job-translate-batch" class="toggle-group"></div>
<div id="job-translate-timeout" class="toggle-group"></div>
<div id="job-translate-concurrency" class="toggle-group"></div>
<div id="job-translate-signs" class="toggle-group"></div>
</div>
<div class="setting-group">
<button class="btn btn-ghost" id="job-translate-test">Test connection</button>
<br /><br />
<span id="job-translate-test-result"></span>
</div>
</div>
</details>
<div class="setting-group">
<label>Subtitle language priority</label>
<div id="job-subtitle-lang-priority"></div>
Expand Down
Loading
Loading