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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dist/
input/
output/
temp/
fonts-user/
vapoursynth-user/
*.log
bun.lock
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ RUN python3 -m venv /opt/vs-venv \
vapoursynth-deblock \
vapoursynth-hysteresis \
ffms2 \
fonttools \
&& /opt/vs-venv/bin/vapoursynth config \
&& ln -sf /opt/vs-venv/bin/python /usr/local/bin/python \
&& ln -sf /opt/vs-venv/bin/python3 /usr/local/bin/python3 \
Expand All @@ -194,6 +195,7 @@ RUN mkdir -p /root/.config/vsrepo \
# Make the venv's Python the default for any 'python3' call
ENV PATH="/opt/vs-venv/bin:${PATH}"

COPY fonts/ /app/fonts/
COPY vapoursynth/ /app/vapoursynth/

# Download all binaries from CDN
Expand Down
166 changes: 107 additions & 59 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docker-compose.override.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ services:
- ./input:/data/input
- ./output:/data/output
- ./temp:/data/temp
- ./fonts-user:/config/fonts
- ./vapoursynth-user:/config/vapoursynth
# Mount your media library folders for in-place encoding
# - /mnt/HDD/media/Animes:/Animes
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ services:
- ./input:/data/input
- ./output:/data/output
- ./temp:/data/temp
- ./fonts-user:/config/fonts
- ./vapoursynth-user:/config/vapoursynth
# Mount your media library folders for in-place encoding
# - /mnt/HDD/media/Animes:/Animes
Expand Down
Binary file added fonts/Noto Sans/ar.ttf
Binary file not shown.
Binary file added fonts/Noto Sans/he.ttf
Binary file not shown.
Binary file added fonts/Noto Sans/hi.ttf
Binary file not shown.
Binary file added fonts/Noto Sans/hk.ttf
Binary file not shown.
Binary file added fonts/Noto Sans/ja.ttf
Binary file not shown.
Binary file added fonts/Noto Sans/ko.ttf
Binary file not shown.
Binary file added fonts/Noto Sans/latin.ttf
Binary file not shown.
8 changes: 8 additions & 0 deletions fonts/Noto Sans/metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"label": "Noto Sans",
"faces": {
"sc.ttf": { "keys": ["zh", "zh-cn", "zh-hans", "zh-sg", "chs"] },
"tc.ttf": { "keys": ["zh-tw", "zh-hant", "cht", "zh-mo"] },
"hk.ttf": { "keys": ["zh-hk", "yue", "zh-hant-hk"] }
}
}
Binary file added fonts/Noto Sans/sc.ttf
Binary file not shown.
Binary file added fonts/Noto Sans/tc.ttf
Binary file not shown.
Binary file added fonts/Noto Sans/th.ttf
Binary file not shown.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"name": "rabbit-encoder",
"version": "6.7.0",
"version": "7.0.0",
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts"
"start": "bun run src/index.ts",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "^1.3.14"
Expand Down
32 changes: 31 additions & 1 deletion public/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Blake2b from "@rabbit-company/blake2b";
import type { Job, JobSettings, PreviewState, VsFilterEntry, VsPresetManifest } from "../types";
import type { FontAxis, Job, JobSettings, PreviewState, VsFilterEntry, VsPresetManifest } from "../types";
import type { BenchmarkState, FetchOptions, GpuDevice, SystemStats } from "../ui/models";
import { API } from "../config/api-base";
import { startPolling, stopPolling } from "../features/polling";
Expand Down Expand Up @@ -127,6 +127,36 @@ export async function fetchConfig(): Promise<JobSettings> {
return res.json();
}

export interface FontOption {
label: string;
faces?: { fileName: string; family: string; keys: string[]; axes: FontAxis[] }[];
}

export async function fetchFonts(): Promise<FontOption[]> {
try {
const res = await authFetch(`${API}/api/fonts`);
return (await res.json()).fonts || [];
} catch {
return [];
}
}

export async function resolveFontFace(family: string, text: string): Promise<{ fileName: string | null; family: string | null }> {
try {
const q = new URLSearchParams({ family, text: text.slice(0, 400) });
const res = await authFetch(`${API}/api/fonts/resolve?${q}`);
return await res.json();
} catch {
return { fileName: null, family: null };
}
}

export async function fetchFontFace(family: string, fileName: string): Promise<Blob> {
const res = await authFetch(`${API}/api/fonts/face/${encodeURIComponent(family)}/${encodeURIComponent(fileName)}`);
if (!res.ok) throw new Error("Font fetch failed");
return res.blob();
}

export async function fetchQueueState(): Promise<{ paused: boolean }> {
const res = await authFetch(`${API}/api/queue`);
return res.json();
Expand Down
7 changes: 7 additions & 0 deletions public/app/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { asElementTarget, byId, inputById } from "../shared/dom";
import { appState } from "../state";
import { delegateClick } from "../ui/delegate";
import { dispatchJobAction } from "../ui/job-actions";
import { closeSubStyleModal, closeSubStyleModalIfOutside, openSubStyleModal } from "../features/sub-style-modal";

export function getCurrentSettings(): JobSettings | null {
if (appState.currentAdvancedTarget === "default") return window._tempDefaults ?? null;
Expand All @@ -72,6 +73,12 @@ export function initEventListeners() {
byId("close-advanced-done-btn").addEventListener("click", closeAdvancedModal);
byId("advanced-modal").addEventListener("click", closeAdvancedModalIfOutside);

byId("default-open-sub-style-btn").addEventListener("click", () => openSubStyleModal("default"));
byId("job-open-sub-style-btn").addEventListener("click", () => openSubStyleModal("job"));
byId("close-sub-style-modal-btn").addEventListener("click", closeSubStyleModal);
byId("close-sub-style-done-btn").addEventListener("click", closeSubStyleModal);
byId("sub-style-modal").addEventListener("click", closeSubStyleModalIfOutside);

byId("vs-reload-btn").onclick = async () => {
await reloadVsPresets();
const settings = getCurrentSettings();
Expand Down
19 changes: 19 additions & 0 deletions public/config/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
EncoderQuality,
EncoderSpeed,
SubtitleProcessingMode,
SubtitleStyle,
VideoEncodeMode,
} from "../types";
import type { PipelinePreset } from "../ui/models";
Expand Down Expand Up @@ -84,3 +85,21 @@ export const PIPELINE_PRESET_HELP: Record<PipelinePreset, string> = {
prepare: "Run denoise & VS only; pass audio/subs/video through (FFV1). For GPU-only servers.",
custom: "Configure each pipeline stage individually below.",
};

export const DEFAULT_SUBTITLE_STYLE: SubtitleStyle = {
fontName: "Noto Sans",
fontSize: 74,
primaryColour: "&H00FFFFFF",
outlineColour: "&H00000000",
backColour: "&H80000000",
outline: 4,
shadow: 1.5,
alignment: 2,
marginV: 50,
marginL: 135,
marginR: 135,
bold: false,
fontAxes: {
wght: 700,
},
};
213 changes: 213 additions & 0 deletions public/features/font-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import type { SubtitleStyle } from "../types";
import { fetchFontFace, resolveFontFace } from "../api/client";

interface PreviewEl extends HTMLElement {
_timer?: ReturnType<typeof setInterval> | null;
_onKey?: (e: KeyboardEvent) => void;
_ro?: ResizeObserver | null;
}

const BG_PRESETS = [
{ key: "1", label: "Black", color: "#000000" },
{ key: "2", label: "White", color: "#ffffff" },
{ key: "3", label: "Red", color: "#c0152f" },
{ key: "4", label: "Green", color: "#1f7a34" },
{ key: "5", label: "Blue", color: "#1f4fd1" },
{ key: "6", label: "Gray", color: "#808080" },
];

/** ASS &HAABBGGRR (AA: 00=opaque, FF=transparent) → CSS rgba(). */
function assColourToCss(ass: string): string {
const m = /^&H([0-9A-Fa-f]{1,8})$/i.exec((ass || "").trim());
if (!m) return "rgba(255,255,255,1)";
const hex = m[1]!.padStart(8, "0");
const a = parseInt(hex.slice(0, 2), 16);
const b = parseInt(hex.slice(2, 4), 16);
const g = parseInt(hex.slice(4, 6), 16);
const r = parseInt(hex.slice(6, 8), 16);
return `rgba(${r},${g},${b},${(1 - a / 255).toFixed(3)})`;
}

/** ASS numpad alignment (1–9) -> flex placement + text-align. */
function alignToFlex(al: number) {
const col = (al - 1) % 3; // 0 left, 1 center, 2 right
const row = Math.floor((al - 1) / 3); // 0 bottom, 1 middle, 2 top
return {
justify: row === 0 ? "flex-end" : row === 1 ? "center" : "flex-start",
align: col === 0 ? "flex-start" : col === 1 ? "center" : "flex-end",
textAlign: col === 0 ? "left" : col === 1 ? "center" : ("right" as CanvasTextAlign),
};
}

export function renderFontPreview(container: PreviewEl, style: SubtitleStyle): void {
// Tear down a previous instance (modals are hidden, not removed, so reuse is common).
if (container._timer) clearInterval(container._timer);
if (container._onKey) document.removeEventListener("keydown", container._onKey);
if (container._ro) container._ro.disconnect();
container._timer = null;
container._onKey = undefined;
container._ro = null;
container.innerHTML = "";

let bg = "#000000";
let sampleText = "Bow before the one\nwho conquered death itself.";
let loadedFamily = "";
let resolveTimer: ReturnType<typeof setTimeout> | null = null;

// toolbar
const bar = document.createElement("div");
bar.style.cssText = "display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-bottom:8px";

for (const p of BG_PRESETS) {
const sw = document.createElement("button");
sw.type = "button";
sw.title = `${p.label} (key ${p.key})`;
sw.style.cssText = `width:24px;height:24px;border-radius:4px;border:1px solid rgba(128,128,128,.5);cursor:pointer;background:${p.color}`;
sw.onclick = () => setBg(p.color);
bar.appendChild(sw);
}

const picker = document.createElement("input");
picker.type = "color";
picker.value = "#000000";
picker.title = "Custom background";
picker.style.cssText = "width:28px;height:24px;padding:0;border:1px solid rgba(128,128,128,.5);border-radius:4px;cursor:pointer;background:none";
picker.oninput = () => setBg(picker.value);
bar.appendChild(picker);

const textInput = document.createElement("input");
textInput.type = "text";
textInput.className = "lang-filter-input";
textInput.value = sampleText.replace(/\n/g, " / ");
textInput.placeholder = "Sample text ( / = line break )";
textInput.style.cssText = "flex:1;min-width:140px";
textInput.oninput = () => {
sampleText = textInput.value.replace(/\s*\/\s*/g, "\n");
applyStyles();
};
bar.appendChild(textInput);

// stage (16:9, represents 1920×1080)
const stage = document.createElement("div");
stage.style.cssText = "position:relative;width:100%;aspect-ratio:16 / 9;border-radius:6px;overflow:hidden;border:1px solid rgba(128,128,128,.4)";

const flex = document.createElement("div");
flex.style.cssText = "position:absolute;inset:0;display:flex;flex-direction:column;box-sizing:border-box";
const textEl = document.createElement("div");
textEl.style.cssText = "max-width:100%;overflow-wrap:anywhere";
flex.appendChild(textEl);
stage.appendChild(flex);

const hint = document.createElement("div");
hint.className = "setting-help";
hint.style.marginTop = "6px";
hint.textContent = "Approximate preview. Keys 1-6 switch backgrounds or use the colour picker. libass rendering may differ slightly.";

container.appendChild(bar);
container.appendChild(stage);
container.appendChild(hint);

function setBg(color: string) {
bg = color;
if (/^#[0-9a-f]{6}$/i.test(color)) picker.value = color;
applyStyles();
}

function ensureFace() {
if (resolveTimer) clearTimeout(resolveTimer);
resolveTimer = setTimeout(async () => {
const familyLabel = style.fontName;
if (!familyLabel) return;
const { fileName, family } = await resolveFontFace(familyLabel, sampleText);
if (!fileName || !family || family === loadedFamily) {
if (family) {
textEl.style.fontFamily = `"${family}", "Arial", sans-serif`;
}
return;
}
try {
const blob = await fetchFontFace(familyLabel, fileName);
const face = new FontFace(family, `url(${URL.createObjectURL(blob)})`);
await face.load();
document.fonts.add(face);
loadedFamily = family;
textEl.style.fontFamily = `"${family}", "Arial", sans-serif`;
} catch {
/* fall back to system rendering */
}
}, 250);
}

function applyStyles() {
const w = stage.getBoundingClientRect().width || 0;
const h = stage.getBoundingClientRect().height || 0;
const scale = w > 0 ? w / 1920 : 0;
const a = alignToFlex(style.alignment || 2);

flex.style.background = bg;
flex.style.justifyContent = a.justify;
flex.style.alignItems = a.align;
flex.style.paddingBottom = `${style.marginV * scale}px`;
flex.style.paddingLeft = `${style.marginL * scale}px`;
flex.style.paddingRight = `${style.marginR * scale}px`;

const ol = Math.max(0, style.outline * scale);
const sh = Math.max(0, style.shadow * scale);
textEl.textContent = sampleText;
textEl.style.fontFamily = `"${style.fontName}", "Arial", sans-serif`;
const axisCss = Object.entries(style.fontAxes ?? {})
.map(([t, v]) => `"${t}" ${v}`)
.join(", ");
textEl.style.fontVariationSettings = axisCss || "normal";
if (style.fontAxes?.wght) textEl.style.fontWeight = String(style.fontAxes.wght);
textEl.style.fontSize = `${Math.max(1, style.fontSize * scale)}px`;
textEl.style.fontWeight = style.bold ? "700" : "400";
textEl.style.color = assColourToCss(style.primaryColour);
textEl.style.textAlign = a.textAlign;
textEl.style.lineHeight = "1.2";
textEl.style.whiteSpace = "pre-line";
// -webkit-text-stroke is centred, so ~half sits inside the glyph; double it and
// paint stroke behind fill to approximate libass's outside outline.
(textEl.style as any).webkitTextStrokeWidth = ol > 0 ? `${ol * 2}px` : "0";
(textEl.style as any).webkitTextStrokeColor = assColourToCss(style.outlineColour);
(textEl.style as any).paintOrder = "stroke fill";
textEl.style.textShadow = sh > 0 ? `${sh}px ${sh}px 0 ${assColourToCss(style.backColour)}` : "none";

ensureFace();
}

const onKey = (e: KeyboardEvent) => {
if (container.offsetParent === null) return; // modal hidden
const ae = document.activeElement;
if (ae && /^(INPUT|TEXTAREA|SELECT)$/.test(ae.tagName)) return;
const preset = BG_PRESETS.find((p) => p.key === e.key);
if (preset) {
setBg(preset.color);
e.preventDefault();
}
};
container._onKey = onKey;
document.addEventListener("keydown", onKey);

applyStyles();

const ro = new ResizeObserver(() => applyStyles());
ro.observe(stage);
container._ro = ro;

let last = "";
container._timer = setInterval(() => {
if (!container.isConnected) {
if (container._timer) clearInterval(container._timer);
container._timer = null;
document.removeEventListener("keydown", onKey);
if (container._ro) container._ro.disconnect();
container._ro = null;
return;
}
const snap = JSON.stringify(style) + "|" + bg + "|" + sampleText;
if (snap === last) return;
last = snap;
applyStyles();
}, 250);
}
Loading
Loading