diff --git a/.dockerignore b/.dockerignore index b98ee7d..036dd2c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,7 +3,9 @@ dist/ input/ output/ temp/ +vapoursynth-user/ *.log .git/ .gitignore README.md +ffmpeg-dist/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 17332f1..af78f92 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ input/ output/ temp/ +vapoursynth-user/ *.log bun.lock ffmpeg-dist/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 5089efa..2721514 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ USER root ENV DEBIAN_FRONTEND=noninteractive ENV RUSTICL_ENABLE=radeonsi,iris,nouveau +ENV OCL_ICD_VENDORS=/etc/OpenCL/vendors # Base packages RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -45,15 +46,15 @@ EOF RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ python3-pip \ - vapoursynth \ - python3-vapoursynth-jetpack \ - vapoursynth-akarin \ - vapoursynth-ffms2 \ + python3-venv \ + python3-dev \ + libpython3-dev \ ffmpeg \ mediainfo \ opus-tools \ mkvtoolnix \ zstd \ + p7zip-full \ \ # OpenCL ocl-icd-libopencl1 \ @@ -152,10 +153,48 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libegl1 \ libgles2 \ libglu1-mesa \ - && pip3 install --no-cache-dir --break-system-packages --no-deps vstools \ && rm -f /etc/OpenCL/vendors/mesa.icd \ && rm -rf /var/lib/apt/lists/* +# Create venv, install vsrepo, and symlink it for convenience +RUN python3 -m venv /opt/vs-venv \ + && /opt/vs-venv/bin/pip install --no-cache-dir --upgrade pip setuptools wheel \ + && /opt/vs-venv/bin/pip install --no-cache-dir \ + VapourSynth \ + vsrepo \ + vsjetpack \ + vstools \ + vsutil \ + vapoursynth-adaptivegrain \ + vapoursynth-akarin \ + vapoursynth-awarp \ + vapoursynth-resize2 \ + vapoursynth-vszip \ + vapoursynth-zsmooth \ + vapoursynth-mvtools \ + vapoursynth-eedi3 \ + vapoursynth-znedi3 \ + vapoursynth-descale \ + vapoursynth-deblock \ + vapoursynth-hysteresis \ + ffms2 \ + && /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 \ + && ln -sf /opt/vs-venv/bin/vapoursynth /usr/local/bin/vapoursynth \ + && ln -sf /opt/vs-venv/bin/vspipe /usr/local/bin/vspipe \ + && ln -sf /opt/vs-venv/bin/vsrepo /usr/local/bin/vsrepo + +# Use the venv's vsrepo to install required scripts +RUN mkdir -p /root/.config/vsrepo \ + && vsrepo update \ + && vsrepo install ffms2 fmtc nnedi3 knlm + +# Make the venv's Python the default for any 'python3' call +ENV PATH="/opt/vs-venv/bin:${PATH}" + +COPY vapoursynth/ /app/vapoursynth/ + # Copy bundled binaries and custom FFmpeg archives COPY binaries/ /app/binaries/ @@ -167,16 +206,6 @@ RUN chmod +x \ && if [ -f /app/binaries/x86_64_v4/SvtAv1EncApp ]; then chmod +x /app/binaries/x86_64_v4/SvtAv1EncApp; fi # Extract custom FFmpeg builds. -# -# Expected archive contents: -# ffmpeg-x86-64-v2/ -# ffmpeg-x86-64-v3/ -# ffmpeg-x86-64-v4/ -# -# Final locations: -# /opt/ffmpeg-x86-64-v2 -# /opt/ffmpeg-x86-64-v3 -# /opt/ffmpeg-x86-64-v4 RUN mkdir -p /opt \ && tar --zstd -xpf /app/binaries/x86_64_v2/ffmpeg.tar.zst -C /opt \ && tar --zstd -xpf /app/binaries/x86_64_v3/ffmpeg.tar.zst -C /opt \ @@ -190,7 +219,6 @@ RUN mkdir -p /opt \ /opt/ffmpeg-x86-64-v4/bin/ffprobe # Verify all custom FFmpeg builds can resolve shared libraries. -# This catches missing runtime packages during docker build. RUN set -eux; \ for level in x86-64-v2 x86-64-v3 x86-64-v4; do \ echo "Checking /opt/ffmpeg-$level/bin/ffmpeg"; \ diff --git a/README.md b/README.md index cce5fa0..c9cea7a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Drop media files into the `input` folder and get optimally encoded MKV files in - **Auto-Boost-Essential** integration for optimal per-scene CRF zones - **Opus** audio encoding with configurable per-channel bitrates - **HDR10 metadata** preservation (PQ, BT.2020, mastering display, content light) +- **VapourSynth filter chain** stackable per-job filters (FineDehalo, DehaloAlpha...) with `light` / `medium` / `heavy` presets, full per-parameter overrides, and a hot-reloadable user preset directory for dropping in your own `.vpy` scripts - **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 @@ -35,100 +36,54 @@ cp movie.mkv input/ All 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 | -| `ENCODER_QUALITY` | `medium` | Default video quality (`low`, `medium`, `high`) | -| `ENCODER_SPEED` | `slow` | Default encode speed (`slower`, `slow`, `medium`, `fast`, `faster`) | -| `ENCODER_DENOISE` | `off` | Default denoise level (`off`, `auto`, `light`, `medium`, `heavy`) | -| `ENCODER_DENOISE_BACKEND` | `auto` | Denoise backend: `cpu`, `auto`, `vulkan`, `opencl`. `cpu` forces software nlmeans. | -| `ENCODER_DENOISE_GPU_DEVICE` | `0.0` | GPU device id (ignored when backend is `cpu`). `0` for vulkan, `.` for opencl. | -| `ENCODER_DENOISE_AUTO_THRESHOLD_LIGHT` | `0.5` | Y bitplane-4 threshold above which scenes get `light` denoise (only used when `ENCODER_DENOISE=auto`). | -| `ENCODER_DENOISE_AUTO_THRESHOLD_MEDIUM` | `0.7` | Y bitplane-4 threshold above which scenes get `medium` denoise. | -| `ENCODER_DENOISE_AUTO_THRESHOLD_HEAVY` | `0.9` | Y bitplane-4 threshold above which scenes get `heavy` denoise. | -| `ENCODER_DENOISE_LIGHT_S` | `1.0` | NLMeans strength `s` for `light` level (float [1.0 – 30.0]). | -| `ENCODER_DENOISE_LIGHT_P` | `3` | NLMeans patch size `p` for `light` level (odd int [1 – 99]). | -| `ENCODER_DENOISE_LIGHT_R` | `7` | NLMeans research size `r` for `light` level (odd int [1 – 99]). | -| `ENCODER_DENOISE_MEDIUM_S` | `1.5` | NLMeans `s` for `medium` level. | -| `ENCODER_DENOISE_MEDIUM_P` | `3` | NLMeans `p` for `medium` level. | -| `ENCODER_DENOISE_MEDIUM_R` | `9` | NLMeans `r` for `medium` level. | -| `ENCODER_DENOISE_HEAVY_S` | `2.0` | NLMeans `s` for `heavy` level. | -| `ENCODER_DENOISE_HEAVY_P` | `3` | NLMeans `p` for `heavy` level. | -| `ENCODER_DENOISE_HEAVY_R` | `11` | NLMeans `r` for `heavy` level. | -| `ENCODER_DEBAND` | `off` | Default deband level (`off`, `light`, `medium`, `heavy`). | -| `ENCODER_DEBAND_LIGHT_STRENGTH` | `0.8` | Gradfun strength for `light` level (float [0.51 – 64]). | -| `ENCODER_DEBAND_LIGHT_RADIUS` | `8` | Gradfun radius for `light` level (int [8 – 32]). | -| `ENCODER_DEBAND_MEDIUM_STRENGTH` | `1.4` | Gradfun strength for `medium` level. | -| `ENCODER_DEBAND_MEDIUM_RADIUS` | `16` | Gradfun radius for `medium` level. | -| `ENCODER_DEBAND_HEAVY_STRENGTH` | `2.8` | Gradfun strength for `heavy` level. | -| `ENCODER_DEBAND_HEAVY_RADIUS` | `24` | Gradfun radius for `heavy` level. | -| `ENCODER_DOWNSCALE` | `false` | Downscale 4K sources to 1080p before encoding. | -| `ENCODER_SKIP_BOOSTING` | `false` | Skip boosting — bypass per-scene CRF zone analysis. | -| `ENCODER_DEDUPE_SUBTITLES` | `false` | Keep only one subtitle per language + type. | -| `AUDIO_NO_PHASE_INV` | `false` | Disable phase inversion (`--no-phase-inv`) for AV1 encoding. | -| `AUDIO_LANGUAGES` | _(empty)_ | Comma-separated audio language codes to keep (empty = keep all). | -| `SUBTITLE_LANGUAGES` | _(empty)_ | Comma-separated subtitle language codes to keep (empty = keep all). | -| `ORGANIZATION` | `RabbitCompany` | Tag appended to encoded filenames (e.g. `-RabbitCompany`). | -| `AUDIO_BITRATE_MONO` | `64` | Opus bitrate for mono audio (kbps). | -| `AUDIO_BITRATE_STEREO` | `128` | Opus bitrate for stereo audio. | -| `AUDIO_BITRATE_2_1` | `160` | Opus bitrate for 2.1 audio. | -| `AUDIO_BITRATE_5_1` | `256` | Opus bitrate for 5.1 audio. | -| `AUDIO_BITRATE_6_1` | `320` | Opus bitrate for 6.1 audio. | -| `AUDIO_BITRATE_7_1` | `384` | Opus bitrate for 7.1 audio. | -| `AUDIO_BITRATE_7_1_4` | `448` | Opus bitrate for 7.1.4 Atmos audio. | - -## Web Dashboard - -The dashboard at `http://localhost:3000` shows: - -- **Job queue** with file info, status, and progress -- **Per-file settings** (quality, speed, audio bitrates) editable while queued -- **Live progress** tracking through all encoding stages -- **Results** showing output file size and encode time -- **Preview** generate a small set of comparison samples for any queued job and toggle between source and encode -- **Library browser** for navigating and encoding mounted media folders - -## Library Encoding - -Library encoding lets you browse your media folders directly from the dashboard and encode entire series or movie collections in-place. This is designed for use with **Sonarr**, **Radarr**, and **Jellyfin**. You can download remuxes through Sonarr, then select the series folder in Rabbit Encoder to re-encode everything. - -### How it works - -1. Mount your media folders into the container as volumes -2. Set `LIBRARY_DIRS` to point to those mounted paths -3. Click **Library** in the dashboard header to browse your folders -4. Navigate into a series folder (e.g. `Blue Exorcist (2011)`) and click **Encode Folder** -5. All video files in the folder (and subfolders like Season 01, Season 02, Specials) are queued for encoding - -### What happens when a library file is encoded - -- The encoded file is placed **in the same directory** as the source file -- The **original source file is deleted** -- Associated **`.nfo` and thumbnail files** (`.jpg`, `.png`) matching the source filename are removed so Jellyfin regenerates fresh metadata -- Files that are **already encoded** (filename ends with `-{ORGANIZATION}.mkv`) are automatically skipped - -### Example setup - -```yaml -services: - rabbit-encoder: - image: rabbitcompany/rabbit-encoder:latest - volumes: - - ./input:/data/input - - ./output:/data/output - - ./temp:/data/temp - - /mnt/HDD/media/Animes:/Animes - - /mnt/HDD/media/Shows:/Shows - - /mnt/HDD/media/Movies:/Movies - environment: - - LIBRARY_DIRS=/Animes,/Shows,/Movies -``` - -### Already-encoded detection - -The encoder recognizes files it has already processed by checking the filename suffix. If `ORGANIZATION` is set to `RabbitCompany` (the default), then any file ending with `-RabbitCompany.mkv` is treated as already encoded and will be: +| 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 | +| `ENCODER_QUALITY` | `medium` | Default video quality (`low`, `medium`, `high`) | +| `ENCODER_SPEED` | `slow` | Default encode speed (`slower`, `slow`, `medium`, `fast`, `faster`) | +| `ENCODER_DENOISE` | `off` | Default denoise level (`off`, `auto`, `light`, `medium`, `heavy`) | +| `ENCODER_DENOISE_BACKEND` | `auto` | Denoise backend: `cpu`, `auto`, `vulkan`, `opencl`. `cpu` forces software nlmeans. | +| `ENCODER_DENOISE_GPU_DEVICE` | `0.0` | GPU device id (ignored when backend is `cpu`). `0` for vulkan, `.` for opencl. | +| `ENCODER_DENOISE_AUTO_THRESHOLD_LIGHT` | `0.5` | Y bitplane-4 threshold above which scenes get `light` denoise (only used when `ENCODER_DENOISE=auto`). | +| `ENCODER_DENOISE_AUTO_THRESHOLD_MEDIUM` | `0.7` | Y bitplane-4 threshold above which scenes get `medium` denoise. | +| `ENCODER_DENOISE_AUTO_THRESHOLD_HEAVY` | `0.9` | Y bitplane-4 threshold above which scenes get `heavy` denoise. | +| `ENCODER_DENOISE_LIGHT_S` | `1.0` | NLMeans strength `s` for `light` level (float [1.0 – 30.0]). | +| `ENCODER_DENOISE_LIGHT_P` | `3` | NLMeans patch size `p` for `light` level (odd int [1 – 99]). | +| `ENCODER_DENOISE_LIGHT_R` | `7` | NLMeans research size `r` for `light` level (odd int [1 – 99]). | +| `ENCODER_DENOISE_MEDIUM_S` | `1.5` | NLMeans `s` for `medium` level. | +| `ENCODER_DENOISE_MEDIUM_P` | `3` | NLMeans `p` for `medium` level. | +| `ENCODER_DENOISE_MEDIUM_R` | `9` | NLMeans `r` for `medium` level. | +| `ENCODER_DENOISE_HEAVY_S` | `2.0` | NLMeans `s` for `heavy` level. | +| `ENCODER_DENOISE_HEAVY_P` | `3` | NLMeans `p` for `heavy` level. | +| `ENCODER_DENOISE_HEAVY_R` | `11` | NLMeans `r` for `heavy` level. | +| `ENCODER_DEBAND` | `off` | Default deband level (`off`, `light`, `medium`, `heavy`). | +| `ENCODER_DEBAND_LIGHT_STRENGTH` | `0.8` | Gradfun strength for `light` level (float [0.51 – 64]). | +| `ENCODER_DEBAND_LIGHT_RADIUS` | `8` | Gradfun radius for `light` level (int [8 – 32]). | +| `ENCODER_DEBAND_MEDIUM_STRENGTH` | `1.4` | Gradfun strength for `medium` level. | +| `ENCODER_DEBAND_MEDIUM_RADIUS` | `16` | Gradfun radius for `medium` level. | +| `ENCODER_DEBAND_HEAVY_STRENGTH` | `2.8` | Gradfun strength for `heavy` level. | +| `ENCODER_DEBAND_HEAVY_RADIUS` | `24` | Gradfun radius for `heavy` level. | +| `ENCODER_DOWNSCALE` | `false` | Downscale 4K sources to 1080p before encoding. | +| `ENCODER_SKIP_BOOSTING` | `false` | Skip boosting — bypass per-scene CRF zone analysis. | +| `ENCODER_DEDUPE_SUBTITLES` | `false` | Keep only one subtitle per language + type. | +| `AUDIO_NO_PHASE_INV` | `false` | Disable phase inversion (`--no-phase-inv`) for AV1 encoding. | +| `AUDIO_LANGUAGES` | _(empty)_ | Comma-separated audio language codes to keep (empty = keep all). | +| `SUBTITLE_LANGUAGES` | _(empty)_ | Comma-separated subtitle language codes to keep (empty = keep all). | +| `ORGANIZATION` | `RabbitCompany` | Tag appended to encoded filenames (e.g. `-RabbitCompany`). | +| `AUDIO_BITRATE_MONO` | `64` | Opus bitrate for mono audio (kbps). | +| `AUDIO_BITRATE_STEREO` | `128` | Opus bitrate for stereo audio. | +| `AUDIO_BITRATE_2_1` | `160` | Opus bitrate for 2.1 audio. | +| `AUDIO_BITRATE_5_1` | `256` | Opus bitrate for 5.1 audio. | +| `AUDIO_BITRATE_6_1` | `320` | Opus bitrate for 6.1 audio. | +| `AUDIO_BITRATE_7_1` | `384` | Opus bitrate for 7.1 audio. | +| `AUDIO_BITRATE_7_1_4` | `448` | Opus bitrate for 7.1.4 Atmos audio. | +| `VS_PRESETS_STOCK_DIR` | `/app/vapoursynth/presets` | Directory the built-in VapourSynth presets are loaded from. Override only if you mount a custom stock set. | +| `VS_PRESETS_USER_DIR` | `/config/vapoursynth/presets` | Directory scanned for user-provided VapourSynth presets. Mounted from the host via `./vapoursynth-user:/config/vapoursynth`. | +| `VS_RABBIT_MODULE_DIR` | `/app/vapoursynth` | Path added to `PYTHONPATH` so preset scripts can `import rabbit_vs`. | + +If `ORGANIZATION` is set to `RabbitCompany` (the default), then any file ending with `-RabbitCompany.mkv` is treated as already encoded and will be: - Shown with a green **encoded** badge and dimmed in the library browser - Completely **skipped** when you click Encode Folder @@ -140,7 +95,7 @@ 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 +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) @@ -150,6 +105,39 @@ For each file, the engine runs: 5. **Mux** - Merge video + audio into MKV with metadata tags 6. **HDR** - Apply HDR10 metadata via mkvpropedit (if source is HDR) +## VapourSynth Filters + +Rabbit Encoder ships with a VapourSynth filter system that lets you stack arbitrary preprocessing passes in front of the main encode. Each filter runs as its own `vspipe` pass. + +### Stock presets + +Built-in presets live under `/app/vapoursynth/presets` inside the container and are namespaced as `stock:`: + +| Preset ID | Name | Description | +| -------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `stock:finedehalo` | FineDehalo | Reduces dark/bright halos around high-contrast edges. Best for anime and DVD upscales. | +| `stock:dehalo_alpha` | Dehalo Alpha | Classic halo reduction via `vs-jetpack`'s `dehalo_alpha`. | +| `stock:f3k_deband` | F3K Deband | Removes color banding in smooth gradients while preserving detail. Wraps `vszip.Deband` (modern f3kdb successor) and can re-grain after debanding. | + +Every preset declares its own `levels` (typically `light`, `medium`, `heavy`) and a set of tunable parameters. You can pick a level per job and override individual params per-level in the dashboard's **Advanced Settings -> VapourSynth Filters** panel. + +### User custom presets + +To add your own filter, drop a matching pair of files into the host-mounted user directory (`./vapoursynth-user/presets` by default, exposed inside the container at `/config/vapoursynth/presets`): + +``` +myfilter.vpy # the VapourSynth script +myfilter.json # the manifest (id, levels, params, defaults) +``` + +Both files must share the same stem. The `.vpy` script reads its input path from the `SRC` argument and any tunable parameter via `rabbit_vs.arg_int / arg_float / arg_str / arg_bool`. See [**Examples**](https://github.com/Rabbit-Company/RabbitEncoder/tree/main/vapoursynth/presets). + +User presets are namespaced as `user:` and override nothing (stock and user presets coexist). After editing or adding presets, click **Reload presets** in the Advanced Settings panel (or `POST /api/vs-presets/reload`); no container restart is required. + +### 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. + ## Output Naming Files are named following the pattern: @@ -172,36 +160,39 @@ Source tags are detected from the input filename: `Bluray`, `WEBDL`, `WEBRip`, ` ## 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/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 | -| `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/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 | +| 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/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 | +| `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/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}`. diff --git a/binaries/x86_64_v2/libvszip.so b/binaries/x86_64_v2/libvszip.so deleted file mode 100755 index 8f1cd8c..0000000 Binary files a/binaries/x86_64_v2/libvszip.so and /dev/null differ diff --git a/binaries/x86_64_v3/libvszip.so b/binaries/x86_64_v3/libvszip.so deleted file mode 100755 index e3dbfa6..0000000 Binary files a/binaries/x86_64_v3/libvszip.so and /dev/null differ diff --git a/binaries/x86_64_v4/libvszip.so b/binaries/x86_64_v4/libvszip.so deleted file mode 100755 index 13278c1..0000000 Binary files a/binaries/x86_64_v4/libvszip.so and /dev/null differ diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 841cb89..098727e 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 + - ./vapoursynth-user:/config/vapoursynth # Mount your media library folders for in-place encoding # - /mnt/HDD/media/Animes:/Animes # - /mnt/HDD/media/Shows:/Shows diff --git a/docker-compose.yml b/docker-compose.yml index 794bd38..f6fa380 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - ./input:/data/input - ./output:/data/output - ./temp:/data/temp + - ./vapoursynth-user:/config/vapoursynth # Mount your media library folders for in-place encoding # - /mnt/HDD/media/Animes:/Animes # - /mnt/HDD/media/Shows:/Shows diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 5b2b508..82dd593 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,13 +1,14 @@ #!/bin/sh set -e +export PATH="/opt/vs-venv/bin:${PATH}" + BIN_DIR="/app/binaries" TARGET_SVT="/usr/local/bin/SvtAv1EncApp" TARGET_FFMPEG="/usr/local/bin/ffmpeg" TARGET_FFPROBE="/usr/local/bin/ffprobe" -TARGET_LIB="/usr/lib/x86_64-linux-gnu/vapoursynth/libvszip.so" # Always expose the arch-independent language-detector if ! command -v language-detector >/dev/null 2>&1; then @@ -75,9 +76,8 @@ if [ -x "$ARCH_DIR/SvtAv1EncApp" ]; then ln -sf "$ARCH_DIR/SvtAv1EncApp" "$TARGET_SVT" fi -# Expose VapourSynth zip plugin -if [ -f "$ARCH_DIR/libvszip.so" ]; then - ln -sf "$ARCH_DIR/libvszip.so" "$TARGET_LIB" +if [ -x /opt/vs-venv/bin/vapoursynth ]; then + export VSSCRIPT_PATH="$("/opt/vs-venv/bin/vapoursynth" get-vsscript)" fi # Expose custom FFmpeg @@ -98,10 +98,27 @@ fi # Make matching FFmpeg shared libs visible export LD_LIBRARY_PATH="$FFMPEG_DIR/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" -# Refresh the dynamic linker cache so libvszip.so is findable +# Refresh linker cache where possible ldconfig 2>/dev/null || true echo "[entrypoint] Using FFmpeg: $("$TARGET_FFMPEG" -hide_banner -version | head -n1)" echo "[entrypoint] Using FFprobe: $("$TARGET_FFPROBE" -hide_banner -version | head -n1)" +USER_VS_DIR="/config/vapoursynth/presets" +if [ ! -d "$USER_VS_DIR" ]; then + mkdir -p "$USER_VS_DIR" + cat > "$USER_VS_DIR/README.md" <<'EOF' +Drop your custom VapourSynth presets in this directory. + +Each preset needs two files with the same stem: + myfilter.vpy - the script + myfilter.json - the manifest (id, levels, params, defaults) + +See https://github.com/Rabbit-Company/RabbitEncoder/tree/main/vapoursynth/presets for working examples. + +After adding a preset, hit "Reload presets" in the dashboard's Advanced +Settings panel (or POST /api/vs-presets/reload). +EOF +fi + exec "$@" \ No newline at end of file diff --git a/package.json b/package.json index 6072bcc..9e1ce9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rabbit-encoder", - "version": "4.0.0", + "version": "5.0.0", "type": "module", "scripts": { "dev": "bun run --watch src/index.ts", diff --git a/public/app.js b/public/app.js index 0345a40..1c06ba5 100644 --- a/public/app.js +++ b/public/app.js @@ -10,6 +10,7 @@ let currentEditJobId = null; let authToken = localStorage.getItem("authToken") || ""; let pollTimer = null; let benchmarkPollTimer = null; +let vsPresets = null; const QUALITIES = ["low", "medium", "high"]; const SPEEDS = ["slower", "slow", "medium", "fast", "faster"]; @@ -63,6 +64,7 @@ function previewSettingsFingerprintFE(s) { nlmeansParams: s.nlmeansParams, gradfunParams: s.gradfunParams, autoDenoiseThresholds: s.autoDenoiseThresholds, + vsFilters: s.vsFilters ?? [], }); } @@ -290,6 +292,228 @@ async function cancelPreviewRequest(jobId) { return res.json(); } +async function fetchVsPresets(force = false) { + if (vsPresets !== null && !force) return vsPresets; + const res = await authFetch(`${API}/api/vs-presets`); + const data = await res.json(); + vsPresets = data.presets || []; + return vsPresets; +} + +async function reloadVsPresets() { + await authFetch(`${API}/api/vs-presets/reload`, { method: "POST" }); + return fetchVsPresets(true); +} + +async function fetchVsDefaultEntry(presetId) { + const res = await authFetch(`${API}/api/vs-presets/${encodeURIComponent(presetId)}/default-entry`); + return res.json(); +} + +function renderVsChainEditor(container, settings) { + container.innerHTML = ""; + settings.vsFilters = settings.vsFilters || []; + + fetchVsPresets().then((presets) => { + if (presets.length === 0) { + const empty = document.createElement("div"); + empty.className = "vs-empty"; + empty.textContent = "No VapourSynth presets found."; + container.appendChild(empty); + return; + } + + settings.vsFilters.forEach((entry, idx) => { + const manifest = presets.find((p) => p.id === entry.presetId); + if (!manifest) return; + container.appendChild(renderVsChainEntry(manifest, entry, idx, settings)); + }); + + const addRow = document.createElement("div"); + addRow.className = "vs-add-row"; + + const select = document.createElement("select"); + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "+ Add filter…"; + select.appendChild(placeholder); + for (const p of presets) { + const opt = document.createElement("option"); + opt.value = p.id; + opt.textContent = `${p.name} · ${p.source}`; + select.appendChild(opt); + } + select.onchange = async () => { + if (!select.value) return; + const fresh = await fetchVsDefaultEntry(select.value); + settings.vsFilters.push(fresh); + renderVsChainEditor(container, settings); + }; + + addRow.appendChild(select); + container.appendChild(addRow); + }); +} + +function renderVsChainEntry(manifest, entry, idx, settings) { + const card = document.createElement("div"); + card.className = "vs-entry-card"; + + const header = document.createElement("div"); + header.className = "vs-entry-header"; + + const name = document.createElement("span"); + name.className = "vs-entry-name"; + name.textContent = manifest.name; + header.appendChild(name); + + const badge = document.createElement("span"); + badge.className = `vs-entry-source ${manifest.source === "user" ? "user" : ""}`; + badge.textContent = manifest.source; + header.appendChild(badge); + + const removeBtn = document.createElement("button"); + removeBtn.className = "btn btn-ghost btn-small btn-remove"; + removeBtn.textContent = "Remove"; + removeBtn.onclick = () => { + settings.vsFilters.splice(idx, 1); + renderVsChainEditor(card.parentElement, settings); + }; + header.appendChild(removeBtn); + card.appendChild(header); + + if (manifest.description) { + const desc = document.createElement("div"); + desc.className = "vs-entry-description"; + desc.textContent = manifest.description; + card.appendChild(desc); + } + + const levelOptions = ["off", ...manifest.levels]; + const levelEl = document.createElement("div"); + levelEl.className = "radio-group"; + renderRadioPills(levelEl, levelOptions, entry.level || "off", (v) => { + entry.level = v; + }); + card.appendChild(levelEl); + + const editBtn = document.createElement("button"); + editBtn.className = "vs-entry-edit-btn"; + editBtn.type = "button"; + editBtn.textContent = "Edit values per level"; + + const paramPanel = document.createElement("div"); + paramPanel.className = "vs-param-panel"; + paramPanel.style.display = "none"; + renderVsParamPanel(paramPanel, manifest, entry); + + editBtn.onclick = () => { + const open = paramPanel.style.display !== "none"; + paramPanel.style.display = open ? "none" : ""; + editBtn.classList.toggle("open", !open); + }; + + card.appendChild(editBtn); + card.appendChild(paramPanel); + return card; +} + +function renderVsParamPanel(container, manifest, entry) { + container.innerHTML = ""; + + const grid = document.createElement("div"); + grid.className = "vs-param-grid"; + grid.style.gridTemplateColumns = `minmax(80px, auto) repeat(${manifest.levels.length}, 1fr)`; + + const corner = document.createElement("div"); + corner.className = "vs-param-header vs-param-header-corner"; + corner.textContent = "param"; + grid.appendChild(corner); + for (const lvl of manifest.levels) { + const h = document.createElement("div"); + h.className = "vs-param-header"; + h.textContent = lvl; + grid.appendChild(h); + } + + for (const spec of manifest.params) { + const label = document.createElement("div"); + label.className = "vs-param-row-label"; + label.textContent = spec.label || spec.key; + label.title = spec.key; + grid.appendChild(label); + + for (const lvl of manifest.levels) { + const cell = document.createElement("div"); + cell.className = "vs-param-cell"; + cell.appendChild(renderVsParamInput(spec, lvl, entry)); + grid.appendChild(cell); + } + + if (spec.help) { + const help = document.createElement("div"); + help.className = "vs-param-help"; + help.textContent = spec.help; + grid.appendChild(help); + } + } + + container.appendChild(grid); +} + +function renderVsParamInput(spec, level, entry) { + entry.params = entry.params || {}; + entry.params[level] = entry.params[level] || {}; + const cur = entry.params[level][spec.key]; + + if (spec.type === "bool") { + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = !!cur; + cb.onchange = () => { + entry.params[level][spec.key] = cb.checked; + }; + return cb; + } + + if (spec.type === "enum") { + const sel = document.createElement("select"); + for (const v of spec.enum) { + const opt = document.createElement("option"); + opt.value = v; + opt.textContent = v; + if (v === cur) opt.selected = true; + sel.appendChild(opt); + } + sel.onchange = () => { + entry.params[level][spec.key] = sel.value; + }; + return sel; + } + + const input = document.createElement("input"); + input.type = "number"; + if (spec.min !== undefined) input.min = String(spec.min); + if (spec.max !== undefined) input.max = String(spec.max); + input.step = spec.step !== undefined ? String(spec.step) : spec.type === "int" ? "1" : "0.01"; + input.value = String(cur ?? spec.defaults[level]); + input.onchange = () => { + const n = parseFloat(input.value); + if (!Number.isFinite(n)) return; + const v = spec.type === "int" ? Math.round(n) : n; + const clamped = clampToRange(v, spec.min, spec.max); + entry.params[level][spec.key] = clamped; + input.value = String(clamped); + }; + return input; +} + +function clampToRange(v, min, max) { + if (typeof min === "number" && v < min) v = min; + if (typeof max === "number" && v > max) v = max; + return v; +} + async function fetchPreviewArtifactBlob(jobId, idx, kind) { const cacheKey = `${jobId}:${idx}:${kind}`; const cached = previewBlobCache.get(cacheKey); @@ -407,10 +631,23 @@ function renderPreviewState(state) { clearBtn.style.display = !isRunning && hasResults ? "" : "none"; } +function buildPreviewSampleViews(sample) { + const views = [{ id: "source", label: "Source", role: "source" }]; + for (const f of sample.vsFrames || []) { + views.push({ + id: `vs:${f.index}`, + label: f.label || `VS step ${f.index + 1}`, + role: "vs", + }); + } + views.push({ id: "encode", label: "Encode", role: "encode" }); + return views; +} + function renderPreviewSamples(jobId, samples) { const container = document.getElementById("preview-samples"); - const desiredKey = samples.map((s) => s.index).join(","); + const desiredKey = samples.map((s) => `${s.index}:${(s.vsFrames || []).length}`).join(","); if (container.dataset.renderedKey === desiredKey) return; container.dataset.renderedKey = desiredKey; container.innerHTML = ""; @@ -419,13 +656,18 @@ function renderPreviewSamples(jobId, samples) { const card = document.createElement("div"); card.className = "preview-sample"; card.dataset.idx = String(sample.index); - card.dataset.viewing = "source"; + + const views = buildPreviewSampleViews(sample); + card._views = views; + card._viewIdx = 0; const ts = formatTimestamp(sample.timestampSec); const projected = sample.projectedTotalHuman || "—"; const sizeStr = sample.encodedSizeHuman || "—"; const bitrate = formatBitrate2(sample.encodedBitrateKbps); + const hint = views.length > 2 ? `Click — ${views.length} views` : "Click to toggle"; + card.innerHTML = `
Loading…
@@ -434,7 +676,7 @@ function renderPreviewSamples(jobId, samples) { - Click to toggle + ${hint}
@@ -460,12 +702,20 @@ function renderPreviewSamples(jobId, samples) { (async () => { try { + // Show source immediately. const sourceUrl = await fetchPreviewArtifactBlob(jobId, sample.index, "source"); - await fetchPreviewArtifactBlob(jobId, sample.index, "encode"); // warm cache const img = card.querySelector("img"); img.src = sourceUrl; img.style.display = ""; card.querySelector(".preview-img-loading").style.display = "none"; + + // Warm-cache every other view so cycling/arrow keys are instant. + for (const v of views) { + if (v.id === "source") continue; + fetchPreviewArtifactBlob(jobId, sample.index, v.id).catch(() => { + // Per-view fetch can fail, just ignore. + }); + } } catch (e) { card.querySelector(".preview-img-loading").textContent = `Failed to load: ${e.message || e}`; } @@ -482,24 +732,29 @@ function formatTimestamp(sec) { return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } -async function setPreviewSampleView(card, view) { +async function setPreviewSampleViewByIdx(card, idx) { const jobId = currentPreviewJobId; - const idx = parseInt(card.dataset.idx, 10); - if (!jobId || !Number.isFinite(idx)) return; + const sampleIdx = parseInt(card.dataset.idx, 10); + if (!jobId || !Number.isFinite(sampleIdx)) return; + + const views = card._views || []; + if (views.length === 0) return; + const wrapped = ((idx % views.length) + views.length) % views.length; + const view = views[wrapped]; try { - const url = await fetchPreviewArtifactBlob(jobId, idx, view); + const url = await fetchPreviewArtifactBlob(jobId, sampleIdx, view.id); const img = card.querySelector("img"); - if (img) img.src = url; - card.dataset.viewing = view; + card._viewIdx = wrapped; + card.dataset.viewing = view.id; - const label = view === "source" ? "Source" : "Encode"; const tag = card.querySelector(".preview-sample-tag"); if (tag) { - tag.textContent = label; - tag.classList.toggle("is-source", view === "source"); - tag.classList.toggle("is-encode", view === "encode"); + tag.textContent = view.label; + tag.classList.toggle("is-source", view.role === "source"); + tag.classList.toggle("is-encode", view.role === "encode"); + tag.classList.toggle("is-vs", view.role === "vs"); } if (currentPreviewFullscreenCard === card) { @@ -507,9 +762,10 @@ async function setPreviewSampleView(card, view) { const fsTag = document.getElementById("preview-fullscreen-tag"); if (fsImg) fsImg.src = url; if (fsTag) { - fsTag.textContent = label; - fsTag.classList.toggle("is-source", view === "source"); - fsTag.classList.toggle("is-encode", view === "encode"); + fsTag.textContent = view.label; + fsTag.classList.toggle("is-source", view.role === "source"); + fsTag.classList.toggle("is-encode", view.role === "encode"); + fsTag.classList.toggle("is-vs", view.role === "vs"); } } } catch (e) { @@ -517,16 +773,25 @@ async function setPreviewSampleView(card, view) { } } +function cyclePreviewSampleView(card, direction) { + const views = card._views || []; + if (views.length === 0) return; + const cur = typeof card._viewIdx === "number" ? card._viewIdx : 0; + return setPreviewSampleViewByIdx(card, cur + direction); +} + async function togglePreviewSampleView(card) { - const next = card.dataset.viewing === "source" ? "encode" : "source"; - await setPreviewSampleView(card, next); + return cyclePreviewSampleView(card, +1); } async function openPreviewFullscreen(card) { if (!card) return; currentPreviewFullscreenCard = card; const idx = parseInt(card.dataset.idx, 10); - const view = card.dataset.viewing || "source"; + const views = card._views || []; + const cur = typeof card._viewIdx === "number" ? card._viewIdx : 0; + const view = views[cur] || { id: "source", label: "Source", role: "source" }; + const modal = document.getElementById("preview-fullscreen-modal"); const title = document.getElementById("preview-fullscreen-title"); const img = document.getElementById("preview-fullscreen-img"); @@ -541,11 +806,11 @@ async function openPreviewFullscreen(card) { modal.style.display = ""; try { - const url = await fetchPreviewArtifactBlob(currentPreviewJobId, idx, view); + const url = await fetchPreviewArtifactBlob(currentPreviewJobId, idx, view.id); img.src = url; img.style.display = ""; loading.style.display = "none"; - await setPreviewSampleView(card, view); + await setPreviewSampleViewByIdx(card, cur); } catch (e) { loading.textContent = `Failed to load: ${e.message || e}`; } @@ -1689,6 +1954,7 @@ async function openSettings() { autoDenoiseThresholds: { ...(defaults.autoDenoiseThresholds || DEFAULT_AUTO_THRESHOLDS) }, nlmeansParams: defaults.nlmeansParams ? JSON.parse(JSON.stringify(defaults.nlmeansParams)) : JSON.parse(JSON.stringify(DEFAULT_NLMEANS_PARAMS)), gradfunParams: defaults.gradfunParams ? JSON.parse(JSON.stringify(defaults.gradfunParams)) : JSON.parse(JSON.stringify(DEFAULT_GRADFUN_PARAMS)), + vsFilters: Array.isArray(defaults.vsFilters) ? JSON.parse(JSON.stringify(defaults.vsFilters)) : [], }; window._tempDefaults = tempDefaults; @@ -1784,6 +2050,8 @@ async function openAdvancedModal(target /* "default" | "job" */) { renderGradfunParamsEditor(document.getElementById("advanced-gradfun-params"), settings.gradfunParams, (v) => (settings.gradfunParams = v)); + renderVsChainEditor(document.getElementById("advanced-vs-chain"), settings); + document.getElementById("advanced-modal").style.display = ""; } @@ -2094,6 +2362,7 @@ async function openJobSettings(jobId) { autoDenoiseThresholds: { ...(job.settings.autoDenoiseThresholds || DEFAULT_AUTO_THRESHOLDS) }, nlmeansParams: job.settings.nlmeansParams ? JSON.parse(JSON.stringify(job.settings.nlmeansParams)) : JSON.parse(JSON.stringify(DEFAULT_NLMEANS_PARAMS)), gradfunParams: job.settings.gradfunParams ? JSON.parse(JSON.stringify(job.settings.gradfunParams)) : JSON.parse(JSON.stringify(DEFAULT_GRADFUN_PARAMS)), + vsFilters: Array.isArray(job.settings.vsFilters) ? JSON.parse(JSON.stringify(job.settings.vsFilters)) : [], }; window._tempJobSettings = tempSettings; @@ -2749,6 +3018,11 @@ function initEventListeners() { document.getElementById("close-advanced-done-btn").addEventListener("click", closeAdvancedModal); document.getElementById("advanced-modal").addEventListener("click", closeAdvancedModalIfOutside); + document.getElementById("vs-reload-btn").onclick = async () => { + await reloadVsPresets(); + renderVsChainEditor(document.getElementById("advanced-vs-chain"), getCurrentSettings()); + }; + document.getElementById("sub-preview-modal").addEventListener("click", closeSubPreviewIfOutside); document.getElementById("logout-btn").addEventListener("click", logout); @@ -2855,8 +3129,27 @@ function initEventListeners() { document.getElementById("preview-fullscreen-stage").addEventListener("click", () => { if (currentPreviewFullscreenCard) togglePreviewSampleView(currentPreviewFullscreenCard); }); + document.addEventListener("keydown", (e) => { - if (e.key === "Escape") closePreviewFullscreen(); + if (e.key === "Escape") { + closePreviewFullscreen(); + return; + } + + // Arrow / Space navigation only fires when fullscreen preview is open. + if (!currentPreviewFullscreenCard) return; + + // Don't hijack typing in inputs. + const t = e.target; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; + + if (e.key === "ArrowRight" || e.code === "Space") { + e.preventDefault(); + cyclePreviewSampleView(currentPreviewFullscreenCard, +1); + } else if (e.key === "ArrowLeft") { + e.preventDefault(); + cyclePreviewSampleView(currentPreviewFullscreenCard, -1); + } }); document.getElementById("open-library-btn").addEventListener("click", openLibrary); diff --git a/public/index.html b/public/index.html index bfbf1fa..acb6a64 100644 --- a/public/index.html +++ b/public/index.html @@ -260,6 +260,16 @@

Advanced Settings

strength = max change per pixel [0.51 – 64], radius = neighbourhood size [8 – 32].
+ +
+ +
+
+ Each filter runs as its own pass before the FFmpeg filter chain. VapourSynth filters are CPU-bound and slower than FFmpeg filters – expect + noticeably longer prepare times. +
+ +