diff --git a/.gitignore b/.gitignore index 4a75cc9..c3a0932 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ input/ output/ temp/ +fonts-user/ vapoursynth-user/ *.log bun.lock diff --git a/Dockerfile b/Dockerfile index 2223a77..37c69ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ @@ -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 diff --git a/README.md b/README.md index d2fcd83..55c65c5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Drop media files into the `input` folder and get optimally encoded MKV files in - **Web dashboard** for monitoring progress and configuring per-file settings - **File watcher** auto-detects new files in the input directory - **Queue system** processes files sequentially, with drag-and-drop reordering and pause/resume -- **Preview encoding** generate 6 short comparison clips spread across the source so you can A/B source vs encode before committing the full job +- **Preview encoding** generates configurable short samples spread across the source, with frame-aligned source/encode comparisons before committing the full job - **Library encoding** browse mounted media folders from the UI and encode in-place, replacing source files - **Jellyfin / Sonarr integration** automatically cleans up `.nfo` and thumbnail files when replacing sources so metadata is regenerated - **Smart skip** already-encoded files (detected by `-{ORGANIZATION}` suffix) are recognized and skipped @@ -36,12 +36,21 @@ cp movie.mkv input/ Settings are configurable via environment variables in `docker-compose.yml`: -| Variable | Default | Description | -| --------------- | --------------- | ---------------------------------------------------------------- | -| `PORT` | `3000` | Web dashboard port | -| `PASSWORD` | `rabbitencoder` | Password to access web dashboard | -| `FILE_COOLDOWN` | `30` | Seconds the file size must stay unchanged before encoding starts | -| `ORGANIZATION` | `RabbitCompany` | Tag appended to encoded filenames (e.g. `-RabbitCompany`). | +| Variable | Default | Description | +| ---------------------- | ----------------------------- | ---------------------------------------------------------------- | +| `PORT` | `3000` | Web dashboard port | +| `PASSWORD` | `rabbitencoder` | Password to access web dashboard | +| `FILE_COOLDOWN` | `30` | Seconds the file size must stay unchanged before encoding starts | +| `ORGANIZATION` | `RabbitCompany` | Tag appended to encoded filenames (e.g. `-RabbitCompany`) | +| `INPUT_DIR` | `/data/input` | Watched input directory | +| `OUTPUT_DIR` | `/data/output` | Encoded output directory | +| `TEMP_DIR` | `/data/temp` | Temporary encode and preview files | +| `LIBRARY_DIRS` | empty | Comma-separated mounted library roots | +| `FONTS_STOCK_DIR` | `/app/fonts` | Built-in font directory | +| `FONTS_USER_DIR` | `/config/fonts` | User font directory | +| `VS_PRESETS_STOCK_DIR` | `/app/vapoursynth/presets` | Built-in VapourSynth preset directory | +| `VS_PRESETS_USER_DIR` | `/config/vapoursynth/presets` | User VapourSynth preset directory | +| `VS_RABBIT_MODULE_DIR` | `/app/vapoursynth` | Rabbit Encoder VapourSynth helper-module directory | If `ORGANIZATION` is set to `RabbitCompany` (the default), then any file ending with `-RabbitCompany.mkv` is treated as already encoded and will be: @@ -54,16 +63,29 @@ This means you can safely run Encode Folder on the same series multiple times (o For each file, the engine runs: -1. **Probe** - Extract media info (resolution, audio layout, HDR metadata) -2. **Prepare** - Extract the best video stream into a clean container, then run any configured **VapourSynth filter chain** (each enabled filter is piped through `vspipe` -> FFmpeg as its own pass, before the FFmpeg filter chain) -3. **Auto-Boost-Essential** - 4-stage video encoding: - - Fast pass for scene analysis - - Quality metric calculation (XPSNR) - - Optimal CRF zone generation - - Final encode with per-scene CRF adjustments -4. **Audio** - Encode audio tracks to Opus via FLAC pipe -5. **Mux** - Merge video + audio into MKV with metadata tags -6. **HDR** - Apply HDR10 metadata via mkvpropedit (if source is HDR) +1. **Probe** - Extract media information such as resolution, audio layout, frame rate, subtitle tracks, and HDR metadata. +2. **Prepare** - Extract the primary video stream, run the configured VapourSynth passes, and apply the FFmpeg crop/downscale/denoise/deband chain. +3. **Auto-Boost-Essential** - Run scene analysis, quality metrics, CRF-zone generation, and the final AV1 encode. +4. **Audio** - Sort, filter, deduplicate, and encode selected audio tracks to Opus through a FLAC pipe, or copy them when configured. +5. **Subtitles and fonts** + - Sort, filter, deduplicate, and rename subtitle tracks. + - Optionally convert SRT subtitles to ASS. + - Restyle dialogue-classified ASS styles without changing signs, songs, or other untouched styles. + - Resolve language/script-specific font faces. + - Instance selected variable-font axes into static font files. + - Remove unused source font attachments when enabled. + - Preserve source fonts still referenced by surviving untouched styles or inline `\fn` overrides. + - Use numbered aliases such as `Noto Sans 2` only when a retained source font already occupies the requested family name. +6. **Mux** - Merge video, audio, subtitles, chapters, fonts, and metadata into the final MKV. +7. **HDR** - Apply HDR10 metadata with `mkvpropedit` when required. + +## Preview Encoding + +Preview encoding creates configurable samples distributed across the source so source and encoded results can be compared before running the full job. + +Each sample starts from an exact, lossless FFV1 video window rather than a stream-copied GOP fragment. This prevents keyframe preroll and timestamp offsets from causing source and encoded comparison images to land on different frames. The default is 6 samples, and both the sample count and duration can be changed through the dashboard or preview API. + +Intermediate VapourSynth and prepare-filter stills are also exposed when those stages are active. ## VapourSynth Filters @@ -96,7 +118,35 @@ User presets are namespaced as `user:` and override nothing (stock and user ### Per-job behavior -The VapourSynth chain is stored per job, so different files in the same queue can use different filter stacks. Each entry has a `level` (or `"off"` to disable without removing it from the chain) and a per-level param map, letting you switch intensity without losing your tweaks at other levels. The active chain is also baked into the output MKV's `SETTINGS` tag (e.g. `VS finedehalo/medium+dehalo_alpha/light`) for traceability. +The VapourSynth chain is stored per job, so different files in the same queue can use different filter stacks. Each entry has a `level` (or `"off"` to disable without removing it from the chain) and a per-level parameter map, letting you switch intensity without losing your tweaks at other levels. The active chain and its resolved parameters are stored in the output MKV's versioned `SETTINGS` code (for example, `RE1|...`) so the configuration can be reproduced later. + +## Subtitle Fonts + +Rabbit Encoder can choose different files from one configured font family according to subtitle language and detected script. This allows a family to provide separate Latin, Arabic, Thai, Simplified Chinese, Traditional Chinese, and other script-specific faces. + +Variable-font axes such as `wght` are converted into static font instances before attachment. Axis values are not included in visible attachment names or ASS family names. + +When a retained source font already uses the selected internal family name, Rabbit Encoder chooses the first available numbered alias: + +- `Noto Sans` +- `Noto Sans 2` +- `Noto Sans 3` + +The selected alias is written consistently to the static font's internal metadata, the MKV attachment filename, and the ASS styles Rabbit Encoder rewrites. + +With **Remove unused fonts** enabled, source attachments are retained only when surviving untouched ASS styles or inline `\fn` overrides still reference them. A source font used only by dialogue that Rabbit Encoder restyles does not cause the original attachment to be kept. + +### ASS PlayRes scaling + +Configured subtitle dimensions are authored in 1920×1080 space. When Rabbit Encoder restyles an existing ASS file, it preserves that file's original `PlayResX` and `PlayResY` and scales pixel-based values into the script's coordinate system. + +For example, a configured 64 px font becomes 21.333 in a script using `PlayResY: 360`: + +```text +64 × 360 / 1080 = 21.333 +``` + +When rendered over a 1080p video, libass scales it back to approximately the configured 64 px size. Font size, outline, shadow, and margins use the same PlayRes-aware scaling. ## Output Naming @@ -130,47 +180,45 @@ The format is versioned with an `RE` prefix, so older codes continue to work ## API Endpoints -| Method | Endpoint | Description | -| -------- | ------------------------------------------- | ----------------------------------------------------------------------------- | -| `GET` | `/api/jobs` | List all jobs | -| `GET` | `/api/jobs/:id` | Get job details | -| `PATCH` | `/api/jobs/:id` | Update job settings (queued only) | -| `DELETE` | `/api/jobs/:id` | Remove a job | -| `POST` | `/api/jobs/:id/retry` | Retry a failed job | -| `POST` | `/api/jobs/:id/cancel` | Cancel an actively encoding job | -| `POST` | `/api/jobs/:id/move` | Move a queued job in the queue (`direction`: `up`, `down`, `top`, `bottom`) | -| `POST` | `/api/jobs/:id/import-code` | Apply a settings code to a queued job, replacing its settings | -| `POST` | `/api/jobs/reorder` | Set the entire queue order from a JSON `{ ids: [...] }` body | -| `GET` | `/api/jobs/:id/audio-preview` | Preview audio reorder/filter/dedup for a job | -| `GET` | `/api/jobs/:id/subtitle-preview` | Preview subtitle reorder/rename for a job | -| `GET` | `/api/jobs/:id/mediainfo` | Run `mediainfo` on the source file and return the report | -| `GET` | `/api/jobs/:id/preview` | Get preview-encode state for a job (`idle`, running, or completed samples) | -| `POST` | `/api/jobs/:id/preview` | Start a preview encode (6 short comparison clips spread across the source) | -| `DELETE` | `/api/jobs/:id/preview` | Cancel a running preview, or clear completed preview artifacts | -| `GET` | `/api/jobs/:id/preview/sample/:index/:kind` | Fetch a preview artifact. `kind`: `source` / `encode` (PNG) or `clip` (MKV) | -| `GET` | `/api/config` | Get default settings | -| `PATCH` | `/api/config` | Update default settings | -| `POST` | `/api/config/reset` | Reset default settings | -| `POST` | `/api/config/import-code` | Import a settings code as the new default settings | -| `POST` | `/api/settings/encode` | Encode a settings object into a shareable settings code (`{ code }`) | -| `POST` | `/api/settings/decode` | Decode a settings code back into a settings object (`{ settings }`) | -| `GET` | `/api/library` | List configured library root directories | -| `GET` | `/api/library/browse` | Browse a library folder (`?path=/data/library/Animes`) | -| `POST` | `/api/library/encode` | Queue all videos in a folder for in-place encoding | -| `GET` | `/api/queue` | Get queue state (paused or running) | -| `POST` | `/api/queue/pause` | Pause encoding - stops current encode, preserves queue | -| `POST` | `/api/queue/resume` | Resume encoding from where it was paused | -| `GET` | `/api/system` | Current system resource usage (CPU, RAM, temp-partition disk, network, GPU) | -| `GET` | `/api/opencl-devices` | List available OpenCL devices | -| `GET` | `/api/vulkan-devices` | List available Vulkan devices | -| `GET` | `/api/benchmark` | Get current benchmark state | -| `POST` | `/api/benchmark` | Start a denoise benchmark run | -| `DELETE` | `/api/benchmark` | Cancel a running benchmark | -| `GET` | `/api/vs-presets` | List all VapourSynth presets (stock + user) with their manifests | -| `POST` | `/api/vs-presets/reload` | Rescan stock and user preset directories from disk and reload the registry | -| `GET` | `/api/vs-presets/:id/default-entry` | Build a fresh filter-chain entry for `:id`, pre-filled with manifest defaults | - -All API endpoints require authentication via `Authorization: Bearer ` header, where the token is the BLAKE2b-512 hash of `rabbitencoder-{PASSWORD}`. +| Method | Endpoint | Description | +| -------- | ------------------------------------------- | ------------------------------------------------------------------------------ | +| `GET` | `/api/jobs` | List all jobs | +| `GET` | `/api/jobs/:id` | Get job details | +| `PATCH` | `/api/jobs/:id` | Update job settings (queued only) | +| `DELETE` | `/api/jobs/:id` | Remove a job | +| `POST` | `/api/jobs/:id/retry` | Retry a failed job | +| `POST` | `/api/jobs/:id/cancel` | Cancel an actively encoding job | +| `POST` | `/api/jobs/:id/move` | Move a queued job in the queue (`direction`: `up`, `down`, `top`, `bottom`) | +| `POST` | `/api/jobs/:id/import-code` | Apply a settings code to a queued job, replacing its settings | +| `POST` | `/api/jobs/reorder` | Set the entire queue order from a JSON `{ ids: [...] }` body | +| `GET` | `/api/jobs/:id/audio-preview` | Preview audio reorder/filter/dedup for a job | +| `GET` | `/api/jobs/:id/subtitle-preview` | Preview subtitle reorder/rename for a job | +| `GET` | `/api/jobs/:id/mediainfo` | Run `mediainfo` on the source file and return the report | +| `GET` | `/api/jobs/:id/preview` | Get preview-encode state for a job (`idle`, running, or completed samples) | +| `POST` | `/api/jobs/:id/preview` | Start a preview encode; optional body configures sample count and duration | +| `DELETE` | `/api/jobs/:id/preview` | Cancel a running preview, or clear completed preview artifacts | +| `GET` | `/api/jobs/:id/preview/sample/:index/:kind` | Fetch source/encode clips, comparison PNGs, VS stills, or prepare-stage stills | +| `GET` | `/api/config` | Get default settings | +| `PATCH` | `/api/config` | Update default settings | +| `POST` | `/api/config/reset` | Reset default settings | +| `POST` | `/api/config/import-code` | Import a settings code as the new default settings | +| `POST` | `/api/settings/encode` | Encode a settings object into a shareable settings code (`{ code }`) | +| `POST` | `/api/settings/decode` | Decode a settings code back into a settings object (`{ settings }`) | +| `GET` | `/api/library` | List configured library root directories | +| `GET` | `/api/library/browse` | Browse a library folder (`?path=/data/library/Animes`) | +| `POST` | `/api/library/encode` | Queue all videos in a folder for in-place encoding | +| `GET` | `/api/queue` | Get queue state (paused or running) | +| `POST` | `/api/queue/pause` | Pause encoding - stops current encode, preserves queue | +| `POST` | `/api/queue/resume` | Resume encoding from where it was paused | +| `GET` | `/api/system` | Current system resource usage (CPU, RAM, temp-partition disk, network, GPU) | +| `GET` | `/api/opencl-devices` | List available OpenCL devices | +| `GET` | `/api/vulkan-devices` | List available Vulkan devices | +| `GET` | `/api/benchmark` | Get current benchmark state | +| `POST` | `/api/benchmark` | Start a denoise benchmark run | +| `DELETE` | `/api/benchmark` | Cancel a running benchmark | +| `GET` | `/api/vs-presets` | List all VapourSynth presets (stock + user) with their manifests | +| `POST` | `/api/vs-presets/reload` | Rescan stock and user preset directories from disk and reload the registry | +| `GET` | `/api/vs-presets/:id/default-entry` | Build a fresh filter-chain entry for `:id`, pre-filled with manifest defaults | ## License diff --git a/docker-compose.override.yml b/docker-compose.override.yml index d264b60..688f82a 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 70b6bc3..e25e66f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/fonts/Noto Sans/ar.ttf b/fonts/Noto Sans/ar.ttf new file mode 100644 index 0000000..85dd0ae Binary files /dev/null and b/fonts/Noto Sans/ar.ttf differ diff --git a/fonts/Noto Sans/he.ttf b/fonts/Noto Sans/he.ttf new file mode 100644 index 0000000..5f5ba19 Binary files /dev/null and b/fonts/Noto Sans/he.ttf differ diff --git a/fonts/Noto Sans/hi.ttf b/fonts/Noto Sans/hi.ttf new file mode 100644 index 0000000..1a12714 Binary files /dev/null and b/fonts/Noto Sans/hi.ttf differ diff --git a/fonts/Noto Sans/hk.ttf b/fonts/Noto Sans/hk.ttf new file mode 100644 index 0000000..3405514 Binary files /dev/null and b/fonts/Noto Sans/hk.ttf differ diff --git a/fonts/Noto Sans/ja.ttf b/fonts/Noto Sans/ja.ttf new file mode 100644 index 0000000..6c730cd Binary files /dev/null and b/fonts/Noto Sans/ja.ttf differ diff --git a/fonts/Noto Sans/ko.ttf b/fonts/Noto Sans/ko.ttf new file mode 100644 index 0000000..36e634d Binary files /dev/null and b/fonts/Noto Sans/ko.ttf differ diff --git a/fonts/Noto Sans/latin.ttf b/fonts/Noto Sans/latin.ttf new file mode 100644 index 0000000..9530d84 Binary files /dev/null and b/fonts/Noto Sans/latin.ttf differ diff --git a/fonts/Noto Sans/metadata.json b/fonts/Noto Sans/metadata.json new file mode 100644 index 0000000..6871fce --- /dev/null +++ b/fonts/Noto Sans/metadata.json @@ -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"] } + } +} diff --git a/fonts/Noto Sans/sc.ttf b/fonts/Noto Sans/sc.ttf new file mode 100644 index 0000000..6043afc Binary files /dev/null and b/fonts/Noto Sans/sc.ttf differ diff --git a/fonts/Noto Sans/tc.ttf b/fonts/Noto Sans/tc.ttf new file mode 100644 index 0000000..2defdb9 Binary files /dev/null and b/fonts/Noto Sans/tc.ttf differ diff --git a/fonts/Noto Sans/th.ttf b/fonts/Noto Sans/th.ttf new file mode 100644 index 0000000..d6905bb Binary files /dev/null and b/fonts/Noto Sans/th.ttf differ diff --git a/package.json b/package.json index de952af..88cbd56 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/public/api/client.ts b/public/api/client.ts index 646a4cc..4894aef 100644 --- a/public/api/client.ts +++ b/public/api/client.ts @@ -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"; @@ -127,6 +127,36 @@ export async function fetchConfig(): Promise { return res.json(); } +export interface FontOption { + label: string; + faces?: { fileName: string; family: string; keys: string[]; axes: FontAxis[] }[]; +} + +export async function fetchFonts(): Promise { + 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 { + 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(); diff --git a/public/app/events.ts b/public/app/events.ts index 4e28eaa..234315c 100644 --- a/public/app/events.ts +++ b/public/app/events.ts @@ -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; @@ -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(); diff --git a/public/config/options.ts b/public/config/options.ts index 536e03e..3e33908 100644 --- a/public/config/options.ts +++ b/public/config/options.ts @@ -10,6 +10,7 @@ import type { EncoderQuality, EncoderSpeed, SubtitleProcessingMode, + SubtitleStyle, VideoEncodeMode, } from "../types"; import type { PipelinePreset } from "../ui/models"; @@ -84,3 +85,21 @@ export const PIPELINE_PRESET_HELP: Record = { 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, + }, +}; diff --git a/public/features/font-preview.ts b/public/features/font-preview.ts new file mode 100644 index 0000000..5cab2ea --- /dev/null +++ b/public/features/font-preview.ts @@ -0,0 +1,213 @@ +import type { SubtitleStyle } from "../types"; +import { fetchFontFace, resolveFontFace } from "../api/client"; + +interface PreviewEl extends HTMLElement { + _timer?: ReturnType | 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 | 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); +} diff --git a/public/features/settings-controls.ts b/public/features/settings-controls.ts index 8b8c6f2..99e537f 100644 --- a/public/features/settings-controls.ts +++ b/public/features/settings-controls.ts @@ -6,6 +6,8 @@ import { clampFloat, clampInt, forceOdd } from "./job-render"; import { byId } from "../shared/dom"; import { errorMessage } from "../shared/errors"; +const SUBTITLE_TARGET_TYPES = ["full", "honorifics", "forced", "sdh", "commentary"] as const; + export function wireEncoderControls(prefix: "default" | "job", settings: JobSettings): void { const id = (s: string) => byId(`${prefix}-${s}`) as HTMLElement; @@ -14,7 +16,7 @@ export function wireEncoderControls(prefix: "default" | "job", settings: JobSett const direct = !def.usesAutoBoost; id("abe-controls").style.display = direct ? "none" : ""; id("manual-controls").style.display = direct ? "" : "none"; - // skip-boosting is an ABE concept — hide it for direct encoders if present + // skip-boosting is an ABE concept - hide it for direct encoders if present const sb = byId(`${prefix}-skip-boosting`); const sbGroup = sb.closest(".setting-group"); if (sbGroup) sbGroup.style.display = direct ? "none" : ""; @@ -771,3 +773,87 @@ function renderSimpleToggle(container: HTMLElement, checked: boolean, labelText: label.appendChild(span); container.appendChild(label); } + +/** Multi-select checkboxes for the track types ASS font-restyle applies to. */ +export function renderSubtitleStyleTargets(container: HTMLElement, value: string[], onChange: (v: string[]) => void): void { + container.innerHTML = ""; + const set = new Set(value); + SUBTITLE_TARGET_TYPES.forEach((t) => { + const label = document.createElement("label"); + label.className = "toggle-label"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = set.has(t); + input.onchange = () => { + if (input.checked) set.add(t); + else set.delete(t); + onChange([...set]); + }; + const span = document.createElement("span"); + span.textContent = `\u00A0${t}`; + label.appendChild(input); + label.appendChild(span); + container.appendChild(label); + }); +} + +/**