1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

feat(server): transcoding hardware acceleration (#3171)

* added transcode configs for nvenc,qsv and vaapi

* updated dev docker compose

* added software fallback

* working vaapi

* minor fixes and added tests

* updated api

* compile libvips

* move hwaccel settings to `hwaccel.yml`

* changed default dockerfile, moved `readdir` call

* removed unused import

* minor cleanup

* fix for arm build

* added documentation, minor fixes

* added intel driver

* updated docs

styling

* uppercase codec and api names

* formatting

* added tests

* updated docs

* removed semicolons

* added link to `hwaccel.yml`

* added newlines

* added `hwaccel` section to docker-compose.prod.yml

* ensure mesa drivers are installed

* switch to mimalloc for sharp

* moved build version and sha256 to json

* let libmfx set the render device

* possible fix for vp9 on qsv

* updated tests

* formatting

* review suggestions

* semicolon

* moved `LD_PRELOAD` to start script

* switched to jellyfin's ffmpeg package

* fixed dockerfile

* use cqp instead of icq for qsv vp9

* updated dockerfile

* added sha256sum for other platforms

* fixtures
This commit is contained in:
Mert 2023-08-01 21:56:10 -04:00 committed by GitHub
parent b9cda59172
commit ee49f470b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 1144 additions and 51 deletions

View file

@ -83,4 +83,5 @@ jobs:
files: | files: |
docker/docker-compose.yml docker/docker-compose.yml
docker/example.env docker/example.env
docker/hwaccel.yml
*.apk *.apk

View file

@ -666,13 +666,13 @@ export interface AssetStatsResponseDto {
* @type {number} * @type {number}
* @memberof AssetStatsResponseDto * @memberof AssetStatsResponseDto
*/ */
'total': number; 'videos': number;
/** /**
* *
* @type {number} * @type {number}
* @memberof AssetStatsResponseDto * @memberof AssetStatsResponseDto
*/ */
'videos': number; 'total': number;
} }
/** /**
* *
@ -2510,6 +2510,12 @@ export interface SystemConfigDto {
* @interface SystemConfigFFmpegDto * @interface SystemConfigFFmpegDto
*/ */
export interface SystemConfigFFmpegDto { export interface SystemConfigFFmpegDto {
/**
*
* @type {TranscodeHWAccel}
* @memberof SystemConfigFFmpegDto
*/
'accel': TranscodeHWAccel;
/** /**
* *
* @type {number} * @type {number}
@ -2858,6 +2864,22 @@ export const TimeGroupEnum = {
export type TimeGroupEnum = typeof TimeGroupEnum[keyof typeof TimeGroupEnum]; export type TimeGroupEnum = typeof TimeGroupEnum[keyof typeof TimeGroupEnum];
/**
*
* @export
* @enum {string}
*/
export const TranscodeHWAccel = {
Nvenc: 'nvenc',
Qsv: 'qsv',
Vaapi: 'vaapi',
Disabled: 'disabled'
} as const;
export type TranscodeHWAccel = typeof TranscodeHWAccel[keyof typeof TranscodeHWAccel];
/** /**
* *
* @export * @export

View file

@ -47,6 +47,9 @@ services:
immich-microservices: immich-microservices:
container_name: immich_microservices container_name: immich_microservices
image: immich-microservices:latest image: immich-microservices:latest
# extends:
# file: hwaccel.yml
# service: hwaccel
build: build:
context: ../server context: ../server
dockerfile: Dockerfile dockerfile: Dockerfile

View file

@ -33,6 +33,9 @@ services:
immich-microservices: immich-microservices:
container_name: immich_microservices container_name: immich_microservices
image: immich-microservices:latest image: immich-microservices:latest
# extends:
# file: hwaccel.yml
# service: hwaccel
build: build:
context: ../server context: ../server
dockerfile: Dockerfile dockerfile: Dockerfile

View file

@ -18,6 +18,9 @@ services:
immich-microservices: immich-microservices:
container_name: immich_microservices container_name: immich_microservices
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
# extends:
# file: hwaccel.yml
# service: hwaccel
command: [ "start.sh", "microservices" ] command: [ "start.sh", "microservices" ]
volumes: volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload

23
docker/hwaccel.yml Normal file
View file

@ -0,0 +1,23 @@
version: "3.8"
# Hardware acceleration for transcoding - Optional
# This is only needed if you want to use hardware acceleration for transcoding.
# Depending on your hardware, you should uncomment the relevant lines below.
services:
hwaccel:
# devices:
# - /dev/dri:/dev/dri # If using Intel QuickSync or VAAPI
# volumes:
# - /usr/lib/wsl:/usr/lib/wsl # If using VAAPI in WSL2
# environment:
# - NVIDIA_DRIVER_CAPABILITIES=all # If using NVIDIA GPU
# - LD_LIBRARY_PATH=/usr/lib/wsl/lib # If using VAAPI in WSL2
# - LIBVA_DRIVER_NAME=d3d12 # If using VAAPI in WSL2
# deploy: # Uncomment this section if using NVIDIA GPU
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]

View file

@ -0,0 +1,60 @@
# Hardware Transcoding [Experimental]
This feature allows you to use a GPU or Intel Quick Sync to accelerate transcoding and reduce CPU load.
Note that hardware transcoding is much less efficient for file sizes.
As this is a new feature, it is still experimental and may not work on all systems.
## Supported APIs
- NVENC
- NVIDIA GPUs
- Quick Sync
- Intel CPUs
- VAAPI
- GPUs
## Limitations
- The instructions and configurations here are specific to Docker Compose. Other container engines may require different configuration.
- Only Linux and Windows (through WSL2) servers are supported.
- WSL2 does not support Quick Sync.
- Raspberry Pi is currently not supported.
- Two-pass mode is only supported for NVENC. Other APIs will ignore this setting.
- Only encoding is currently hardware accelerated, so the CPU is still used for software decoding.
- This is mainly because the original video may not be hardware-decodable.
- Hardware dependent
- Codec support varies, but H.264 and HEVC are usually supported.
- Notably, NVIDIA and AMD GPUs do not support VP9 encoding.
- Newer devices tend to have higher transcoding quality.
## Prerequisites
#### NVENC
- You must have the official NVIDIA driver installed on the server.
- On Linux (except for WSL2), you also need to have [NVIDIA Container Runtime][nvcr] installed.
#### QSV
- For VP9 to work:
- You must have a 9th gen Intel CPU or newer
- If you have an 11th gen CPU or older, then you may need to follow [these][jellyfin-lp] instructions as Low-Power mode is required
- Additionally, if the server specifically has an 11th gen CPU and is running kernel 5.15 (shipped with Ubuntu 22.04 LTS), then you will need to upgrade this kernel (from [Jellyfin docs][jellyfin-kernel-bug])
## Setup
1. If you do not already have it, download the latest [`hwaccel.yml`][hw-file] file and ensure it's in the same folder as the `docker-compose.yml`.
2. Uncomment the lines that apply to your system and desired usage.
3. In the `docker-compose.yml` under `immich-microservices`, uncomment the lines relating to the `hwaccel.yml` file.
4. Redeploy the `immich-microservices` container with these updated settings.
5. In the Admin page under `FFmpeg settings`, change the hardware acceleration setting to the appropriate option and save.
## Tips
- You may want to choose a slower preset than for software transcoding to maintain quality and efficiency
- While you can use VAAPI with Nvidia GPUs and Intel CPUs, prefer the more specific APIs since they're more optimized for their respective devices
[hw-file]: https://github.com/immich-app/immich/releases/latest/download/hwaccel.yml
[nvcr]: https://github.com/NVIDIA/nvidia-container-runtime/
[jellyfin-lp]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#configure-and-verify-lp-mode-on-linux
[jellyfin-kernel-bug]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#known-issues-and-limitations

View file

@ -25,10 +25,18 @@ wget https://github.com/immich-app/immich/releases/latest/download/docker-compos
wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env
``` ```
```bash title="(Optional) Get hwaccel.yml file"
wget https://github.com/immich-app/immich/releases/latest/download/hwaccel.yml
```
or by downloading from your browser and moving the files to the directory that you created. or by downloading from your browser and moving the files to the directory that you created.
Note: If you downloaded the files from your browser, also ensure that you rename `example.env` to `.env`. Note: If you downloaded the files from your browser, also ensure that you rename `example.env` to `.env`.
:::info
Optionally, you can use the [`hwaccel.yml`][hw-file] file to enable hardware acceleration for transcoding. See the [Hardware Transcoding](/docs/features/hardware-transcoding.md) guide for info on how to set this up.
:::
### Step 2 - Populate the .env file with custom values ### Step 2 - Populate the .env file with custom values
<details> <details>
@ -186,4 +194,5 @@ Immich is currently under heavy development, which means you can expect breaking
[compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml [compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
[env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env [env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env
[hw-file]: https://github.com/immich-app/immich/releases/latest/download/hwaccel.yml
[watchtower]: https://containrrr.dev/watchtower/ [watchtower]: https://containrrr.dev/watchtower/

View file

@ -113,6 +113,7 @@ doc/TagResponseDto.md
doc/TagTypeEnum.md doc/TagTypeEnum.md
doc/ThumbnailFormat.md doc/ThumbnailFormat.md
doc/TimeGroupEnum.md doc/TimeGroupEnum.md
doc/TranscodeHWAccel.md
doc/TranscodePolicy.md doc/TranscodePolicy.md
doc/UpdateAlbumDto.md doc/UpdateAlbumDto.md
doc/UpdateAssetDto.md doc/UpdateAssetDto.md
@ -245,6 +246,7 @@ lib/model/tag_response_dto.dart
lib/model/tag_type_enum.dart lib/model/tag_type_enum.dart
lib/model/thumbnail_format.dart lib/model/thumbnail_format.dart
lib/model/time_group_enum.dart lib/model/time_group_enum.dart
lib/model/transcode_hw_accel.dart
lib/model/transcode_policy.dart lib/model/transcode_policy.dart
lib/model/update_album_dto.dart lib/model/update_album_dto.dart
lib/model/update_asset_dto.dart lib/model/update_asset_dto.dart
@ -366,6 +368,7 @@ test/tag_response_dto_test.dart
test/tag_type_enum_test.dart test/tag_type_enum_test.dart
test/thumbnail_format_test.dart test/thumbnail_format_test.dart
test/time_group_enum_test.dart test/time_group_enum_test.dart
test/transcode_hw_accel_test.dart
test/transcode_policy_test.dart test/transcode_policy_test.dart
test/update_album_dto_test.dart test/update_album_dto_test.dart
test/update_asset_dto_test.dart test/update_asset_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/TranscodeHWAccel.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,8 +1,19 @@
FROM node:18.16.0-alpine3.18@sha256:f41850f74ff16a33daff988e2ea06ef8f5daeb6fb84913c7df09552a98caba09 as builder FROM node:18-bookworm@sha256:c85dc4392f44f5de1d0d72dd20a088a542734445f99bed7aa8ac895c706d370d as builder
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN apk add --update-cache build-base imagemagick-dev python3 ffmpeg libraw-dev perl vips-dev vips-heif vips-jxl vips-magick COPY bin/install-ffmpeg.sh build-lock.json ./
RUN sed -i -e's/ main/ main contrib non-free non-free-firmware/g' /etc/apt/sources.list.d/debian.sources
RUN apt-get update && apt-get install -yqq build-essential ninja-build meson pkg-config jq \
libglib2.0-dev libexpat1-dev librsvg2-dev libexif-dev libwebp-dev liborc-0.4-dev libtiff5-dev \
libjpeg62-turbo-dev libgsf-1-dev libspng-dev libraw-dev libjxl-dev libheif-dev \
mesa-va-drivers libmimalloc2.0 $(if [ $(arch) = "x86_64" ]; then echo "intel-media-va-driver-non-free"; fi) \
&& ./install-ffmpeg.sh && apt-get autoremove && apt-get clean && rm -rf /var/lib/apt/lists/*
# debian build for imagemagick has broken RAW support, so build manually
COPY bin/build-imagemagick.sh bin/build-libvips.sh ./
RUN ./build-imagemagick.sh
RUN ./build-libvips.sh
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
@ -15,14 +26,31 @@ FROM builder as prod
RUN npm run build RUN npm run build
RUN npm prune --omit=dev --omit=optional RUN npm prune --omit=dev --omit=optional
FROM node:18-bookworm-slim@sha256:a0cca98f2896135d4c0386922211c1f90f98f27a58b8f2c07850d0fbe1c2104e
FROM node:18.16.0-alpine3.18@sha256:f41850f74ff16a33daff988e2ea06ef8f5daeb6fb84913c7df09552a98caba09
ENV NODE_ENV=production ENV NODE_ENV=production
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN apk add --no-cache ffmpeg imagemagick-dev libraw-dev perl tini vips-dev vips-heif vips-jxl vips-magick COPY bin/install-ffmpeg.sh build-lock.json ./
RUN sed -i -e's/ main/ main contrib non-free non-free-firmware/g' /etc/apt/sources.list.d/debian.sources
RUN apt-get update && apt-get install -yqq tini libheif1 libwebp7 libwebpdemux2 libwebpmux3 mesa-va-drivers \
libjpeg62-turbo libexpat1 librsvg2-2 libjxl0.7 libraw20 libtiff6 libspng0 libexif12 libgcc-s1 libglib2.0-0 \
libgsf-1-114 libopenjp2-7 liblcms2-2 liborc-0.4-0 libopenexr-3-1-30 liblqr-1-0 libltdl7 zlib1g \
mesa-va-drivers libmimalloc2.0 $(if [ $(arch) = "x86_64" ]; then echo "intel-media-va-driver-non-free"; fi) jq wget \
&& ./install-ffmpeg.sh && apt-get remove -yqq jq wget && apt-get autoremove -yqq && apt-get clean && rm -rf /var/lib/apt/lists/* \
&& rm install-ffmpeg.sh && rm build-lock.json
ENV PATH=/usr/lib/jellyfin-ffmpeg:$PATH
COPY --from=prod /usr/local/bin/magick /usr/local/bin/magick
COPY --from=prod /usr/local/include/ImageMagick-7 /usr/local/include/ImageMagick-7
COPY --from=prod /usr/local/bin/vips /usr/local/bin/vips
COPY --from=prod /usr/local/include/vips/ /usr/local/include/vips/
COPY --from=prod /usr/local/lib/ /usr/local/lib/
RUN ldconfig /usr/local/lib
COPY --from=prod /usr/src/app/node_modules ./node_modules COPY --from=prod /usr/src/app/node_modules ./node_modules
COPY --from=prod /usr/src/app/dist ./dist COPY --from=prod /usr/src/app/dist ./dist
@ -34,7 +62,6 @@ COPY package.json package-lock.json ./
COPY start*.sh ./ COPY start*.sh ./
RUN npm link && npm cache clean --force RUN npm link && npm cache clean --force
VOLUME /usr/src/app/upload VOLUME /usr/src/app/upload
EXPOSE 3001 EXPOSE 3001

21
server/bin/build-imagemagick.sh Executable file
View file

@ -0,0 +1,21 @@
#!/bin/bash
set -e
LOCK=$(jq -c '.packages[] | select(.name == "imagemagick")' build-lock.json)
IMAGEMAGICK_VERSION=${IMAGEMAGICK_VERSION:=$(echo $LOCK | jq -r '.version')}
IMAGEMAGICK_SHA256=${IMAGEMAGICK_SHA256:=$(echo $LOCK | jq -r '.sha256')}
echo "$IMAGEMAGICK_SHA256 $IMAGEMAGICK_VERSION.tar.gz" > imagemagick.sha256
mkdir -p ImageMagick
wget -nv https://github.com/ImageMagick/ImageMagick/archive/${IMAGEMAGICK_VERSION}.tar.gz
sha256sum -c imagemagick.sha256
tar -xvf ${IMAGEMAGICK_VERSION}.tar.gz -C ImageMagick --strip-components=1
rm ${IMAGEMAGICK_VERSION}.tar.gz
rm imagemagick.sha256
cd ImageMagick
./configure --with-modules
make -j$(nproc)
make install
cd .. && rm -rf ImageMagick
ldconfig /usr/local/lib

22
server/bin/build-libvips.sh Executable file
View file

@ -0,0 +1,22 @@
#!/bin/bash
set -e
LOCK=$(jq -c '.packages[] | select(.name == "libvips")' build-lock.json)
LIBVIPS_VERSION=${LIBVIPS_VERSION:=$(echo $LOCK | jq -r '.version')}
LIBVIPS_SHA256=${LIBVIPS_SHA256:=$(echo $LOCK | jq -r '.sha256')}
echo "$LIBVIPS_SHA256 vips-$LIBVIPS_VERSION.tar.xz" > libvips.sha256
mkdir -p libvips
wget -nv https://github.com/libvips/libvips/releases/download/v${LIBVIPS_VERSION}/vips-${LIBVIPS_VERSION}.tar.xz
sha256sum -c libvips.sha256
tar -xvf vips-${LIBVIPS_VERSION}.tar.xz -C libvips --strip-components=1
rm vips-${LIBVIPS_VERSION}.tar.xz
rm libvips.sha256
cd libvips
meson setup build --buildtype=release --libdir=lib -Dintrospection=false
cd build
# ninja test # tests set concurrency too high for arm/v7
ninja install
cd .. && rm -rf libvips
ldconfig /usr/local/lib

17
server/bin/install-ffmpeg.sh Executable file
View file

@ -0,0 +1,17 @@
#!/bin/bash
set -e
LOCK=$(jq -c '.packages[] | select(.name == "ffmpeg")' build-lock.json)
export TARGETARCH=${TARGETARCH:=$(dpkg --print-architecture)}
FFMPEG_VERSION=${FFMPEG_VERSION:=$(echo $LOCK | jq -r '.version')}
FFMPEG_SHA256=${FFMPEG_SHA256:=$(echo $LOCK | jq -r '.sha256[$ENV.TARGETARCH]')}
echo "$FFMPEG_SHA256 jellyfin-ffmpeg6_${FFMPEG_VERSION}-bookworm_${TARGETARCH}.deb" > ffmpeg.sha256
wget -nv https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_VERSION}/jellyfin-ffmpeg6_${FFMPEG_VERSION}-bookworm_${TARGETARCH}.deb
sha256sum -c ffmpeg.sha256
apt-get -yqq -f install ./jellyfin-ffmpeg6_${FFMPEG_VERSION}-bookworm_${TARGETARCH}.deb
rm jellyfin-ffmpeg6_${FFMPEG_VERSION}-bookworm_${TARGETARCH}.deb
rm ffmpeg.sha256
ldconfig /usr/lib/jellyfin-ffmpeg/lib

24
server/build-lock.json Normal file
View file

@ -0,0 +1,24 @@
{
"packages": [
{
"name": "imagemagick",
"version": "7.1.1-13",
"sha256": "8e3ce1aaad19da9f2ca444072bcc631d193a219e3ee11c13ad6d3c895044142c"
},
{
"name": "libvips",
"version": "8.14.2",
"sha256": "27dad021f0835a5ab14e541d02abd41e4c3bd012d2196438df5a9e754984f7ce"
},
{
"name": "ffmpeg",
"version": "6.0-4",
"sha256": {
"amd64": "18d98b292b891cde86c2a08e5e989c3430e51a136cdc232bc4162fef3b4f0f44",
"arm64": "67eb1e5a38ac695dd253d9ac290ad0e9fb709e8260449a7445e8460b7db3c516",
"armhf": "a29605ab0eced3511c8a6623504fab5b8bb174a486d87f94bf5522ed9a5970e6"
}
}
]
}

View file

@ -4973,14 +4973,15 @@
"type": "object" "type": "object"
}, },
"AssetStatsResponseDto": { "AssetStatsResponseDto": {
"type": "object",
"properties": { "properties": {
"images": { "images": {
"type": "integer" "type": "integer"
}, },
"total": { "videos": {
"type": "integer" "type": "integer"
}, },
"videos": { "total": {
"type": "integer" "type": "integer"
} }
}, },
@ -4988,8 +4989,7 @@
"images", "images",
"videos", "videos",
"total" "total"
], ]
"type": "object"
}, },
"AssetTypeEnum": { "AssetTypeEnum": {
"enum": [ "enum": [
@ -6547,6 +6547,9 @@
}, },
"SystemConfigFFmpegDto": { "SystemConfigFFmpegDto": {
"properties": { "properties": {
"accel": {
"$ref": "#/components/schemas/TranscodeHWAccel"
},
"crf": { "crf": {
"type": "integer" "type": "integer"
}, },
@ -6581,6 +6584,7 @@
"targetVideoCodec", "targetVideoCodec",
"targetAudioCodec", "targetAudioCodec",
"transcode", "transcode",
"accel",
"preset", "preset",
"targetResolution", "targetResolution",
"maxBitrate", "maxBitrate",
@ -6809,6 +6813,15 @@
], ],
"type": "string" "type": "string"
}, },
"TranscodeHWAccel": {
"enum": [
"nvenc",
"qsv",
"vaapi",
"disabled"
],
"type": "string"
},
"TranscodePolicy": { "TranscodePolicy": {
"enum": [ "enum": [
"all", "all",

View file

@ -1,3 +1,5 @@
import { VideoCodec } from '@app/infra/entities';
export const IMediaRepository = 'IMediaRepository'; export const IMediaRepository = 'IMediaRepository';
export interface ResizeOptions { export interface ResizeOptions {
@ -55,6 +57,10 @@ export interface VideoCodecSWConfig {
getOptions(stream: VideoStreamInfo): TranscodeOptions; getOptions(stream: VideoStreamInfo): TranscodeOptions;
} }
export interface VideoCodecHWConfig extends VideoCodecSWConfig {
getSupportedCodecs(): Array<VideoCodec>;
}
export interface IMediaRepository { export interface IMediaRepository {
// image // image
resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>; resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>;

View file

@ -1,4 +1,4 @@
import { AssetType, SystemConfigKey, TranscodePolicy, VideoCodec } from '@app/infra/entities'; import { AssetType, SystemConfigKey, TranscodeHWAccel, TranscodePolicy, VideoCodec } from '@app/infra/entities';
import { import {
assetStub, assetStub,
newAssetRepositoryMock, newAssetRepositoryMock,
@ -272,6 +272,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-preset ultrafast', '-preset ultrafast',
'-crf 23', '-crf 23',
], ],
@ -309,6 +310,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-preset ultrafast', '-preset ultrafast',
'-crf 23', '-crf 23',
], ],
@ -331,6 +333,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720', '-vf scale=-2:720',
'-preset ultrafast', '-preset ultrafast',
'-crf 23', '-crf 23',
@ -357,6 +360,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-preset ultrafast', '-preset ultrafast',
'-crf 23', '-crf 23',
], ],
@ -380,6 +384,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=720:-2', '-vf scale=720:-2',
'-preset ultrafast', '-preset ultrafast',
'-crf 23', '-crf 23',
@ -404,6 +409,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720', '-vf scale=-2:720',
'-preset ultrafast', '-preset ultrafast',
'-crf 23', '-crf 23',
@ -428,6 +434,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720', '-vf scale=-2:720',
'-preset ultrafast', '-preset ultrafast',
'-crf 23', '-crf 23',
@ -476,6 +483,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720', '-vf scale=-2:720',
'-preset ultrafast', '-preset ultrafast',
'-crf 23', '-crf 23',
@ -505,6 +513,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720', '-vf scale=-2:720',
'-preset ultrafast', '-preset ultrafast',
'-b:v 3104k', '-b:v 3104k',
@ -531,6 +540,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720', '-vf scale=-2:720',
'-preset ultrafast', '-preset ultrafast',
'-crf 23', '-crf 23',
@ -559,6 +569,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720', '-vf scale=-2:720',
'-cpu-used 5', '-cpu-used 5',
'-row-mt 1', '-row-mt 1',
@ -589,6 +600,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720', '-vf scale=-2:720',
'-cpu-used 2', '-cpu-used 2',
'-row-mt 1', '-row-mt 1',
@ -618,6 +630,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720', '-vf scale=-2:720',
'-row-mt 1', '-row-mt 1',
'-crf 23', '-crf 23',
@ -646,6 +659,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720', '-vf scale=-2:720',
'-cpu-used 5', '-cpu-used 5',
'-row-mt 1', '-row-mt 1',
@ -673,6 +687,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720', '-vf scale=-2:720',
'-preset ultrafast', '-preset ultrafast',
'-threads 2', '-threads 2',
@ -700,6 +715,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720', '-vf scale=-2:720',
'-preset ultrafast', '-preset ultrafast',
'-crf 23', '-crf 23',
@ -727,6 +743,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720', '-vf scale=-2:720',
'-preset ultrafast', '-preset ultrafast',
'-threads 2', '-threads 2',
@ -757,6 +774,7 @@ describe(MediaService.name, () => {
'-acodec aac', '-acodec aac',
'-movflags faststart', '-movflags faststart',
'-fps_mode passthrough', '-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720', '-vf scale=-2:720',
'-preset ultrafast', '-preset ultrafast',
'-crf 23', '-crf 23',
@ -765,5 +783,508 @@ describe(MediaService.name, () => {
}, },
); );
}); });
it('should skip transcoding for audioless videos with optimal policy if video codec is correct', async () => {
mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL },
{ key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: '1080p' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
it('should return false if hwaccel is enabled for an unsupported codec', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC },
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toEqual(false);
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
it('should return false if hwaccel option is invalid', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: 'invalid' }]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toEqual(false);
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
it('should set two pass options for nvenc when enabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC },
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' },
{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'],
outputOptions: [
`-vcodec h264_nvenc`,
'-tune hq',
'-qmin 0',
'-g 250',
'-bf 3',
'-b_ref_mode middle',
'-temporal-aq 1',
'-rc-lookahead 20',
'-i_qfactor 0.75',
'-b_qfactor 1.1',
'-acodec aac',
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
'-vf hwupload_cuda,scale_cuda=-2:720',
'-preset p1',
'-b:v 6897k',
'-maxrate 10000k',
'-bufsize 6897k',
'-multipass 2',
],
twoPass: false,
},
);
});
it('should set vbr options for nvenc when max bitrate is enabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC },
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'],
outputOptions: [
`-vcodec h264_nvenc`,
'-tune hq',
'-qmin 0',
'-g 250',
'-bf 3',
'-b_ref_mode middle',
'-temporal-aq 1',
'-rc-lookahead 20',
'-i_qfactor 0.75',
'-b_qfactor 1.1',
'-acodec aac',
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
'-vf hwupload_cuda,scale_cuda=-2:720',
'-preset p1',
'-cq:v 23',
'-maxrate 10000k',
'-bufsize 6897k',
],
twoPass: false,
},
);
});
it('should set cq options for nvenc when max bitrate is disabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'],
outputOptions: [
`-vcodec h264_nvenc`,
'-tune hq',
'-qmin 0',
'-g 250',
'-bf 3',
'-b_ref_mode middle',
'-temporal-aq 1',
'-rc-lookahead 20',
'-i_qfactor 0.75',
'-b_qfactor 1.1',
'-acodec aac',
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
'-vf hwupload_cuda,scale_cuda=-2:720',
'-preset p1',
'-cq:v 23',
],
twoPass: false,
},
);
});
it('should omit preset for nvenc if invalid', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC },
{ key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'],
outputOptions: [
`-vcodec h264_nvenc`,
'-tune hq',
'-qmin 0',
'-g 250',
'-bf 3',
'-b_ref_mode middle',
'-temporal-aq 1',
'-rc-lookahead 20',
'-i_qfactor 0.75',
'-b_qfactor 1.1',
'-acodec aac',
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
'-vf hwupload_cuda,scale_cuda=-2:720',
'-cq:v 23',
],
twoPass: false,
},
);
});
it('should ignore two pass for nvenc if max bitrate is disabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'],
outputOptions: [
`-vcodec h264_nvenc`,
'-tune hq',
'-qmin 0',
'-g 250',
'-bf 3',
'-b_ref_mode middle',
'-temporal-aq 1',
'-rc-lookahead 20',
'-i_qfactor 0.75',
'-b_qfactor 1.1',
'-acodec aac',
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
'-vf hwupload_cuda,scale_cuda=-2:720',
'-preset p1',
'-cq:v 23',
],
twoPass: false,
},
);
});
it('should set options for qsv', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV },
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'],
outputOptions: [
`-vcodec h264_qsv`,
'-g 256',
'-extbrc 1',
'-refs 5',
'-bf 7',
'-acodec aac',
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
'-vf format=nv12,hwupload=extra_hw_frames=64,scale_qsv=-1:720',
'-preset 7',
'-global_quality 23',
'-maxrate 10000k',
'-bufsize 20000k',
],
twoPass: false,
},
);
});
it('should omit preset for qsv if invalid', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV },
{ key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'],
outputOptions: [
`-vcodec h264_qsv`,
'-g 256',
'-extbrc 1',
'-refs 5',
'-bf 7',
'-acodec aac',
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
'-vf format=nv12,hwupload=extra_hw_frames=64,scale_qsv=-1:720',
'-global_quality 23',
],
twoPass: false,
},
);
});
it('should set low power mode for qsv if target video codec is vp9', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV },
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'],
outputOptions: [
`-vcodec vp9_qsv`,
'-g 256',
'-extbrc 1',
'-refs 5',
'-bf 7',
'-low_power 1',
'-acodec aac',
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
'-vf format=nv12,hwupload=extra_hw_frames=64,scale_qsv=-1:720',
'-preset 7',
'-q:v 23',
],
twoPass: false,
},
);
});
it('should return false for qsv if no hw devices', async () => {
storageMock.readdir.mockResolvedValue([]);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toEqual(false);
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
it('should set vbr options for vaapi when max bitrate is enabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI },
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'],
outputOptions: [
`-vcodec h264_vaapi`,
'-acodec aac',
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
'-vf format=nv12,hwupload,scale_vaapi=-2:720',
'-compression_level 7',
'-b:v 6897k',
'-maxrate 10000k',
'-minrate 3448.5k',
'-rc_mode 3',
],
twoPass: false,
},
);
});
it('should set cq options for vaapi when max bitrate is disabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'],
outputOptions: [
`-vcodec h264_vaapi`,
'-acodec aac',
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
'-vf format=nv12,hwupload,scale_vaapi=-2:720',
'-compression_level 7',
'-qp 23',
'-global_quality 23',
'-rc_mode 1',
],
twoPass: false,
},
);
});
it('should omit preset for vaapi if invalid', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI },
{ key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'],
outputOptions: [
`-vcodec h264_vaapi`,
'-acodec aac',
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
'-vf format=nv12,hwupload,scale_vaapi=-2:720',
'-qp 23',
'-global_quality 23',
'-rc_mode 1',
],
twoPass: false,
},
);
});
it('should prefer gpu for vaapi if available', async () => {
storageMock.readdir.mockResolvedValue(['renderD129', 'card1', 'card0', 'renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/card1', '-filter_hw_device accel'],
outputOptions: [
`-vcodec h264_vaapi`,
'-acodec aac',
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
'-vf format=nv12,hwupload,scale_vaapi=-2:720',
'-compression_level 7',
'-qp 23',
'-global_quality 23',
'-rc_mode 1',
],
twoPass: false,
},
);
storageMock.readdir.mockResolvedValue(['renderD129', 'renderD128']);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD129', '-filter_hw_device accel'],
outputOptions: [
`-vcodec h264_vaapi`,
'-acodec aac',
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
'-vf format=nv12,hwupload,scale_vaapi=-2:720',
'-compression_level 7',
'-qp 23',
'-global_quality 23',
'-rc_mode 1',
],
twoPass: false,
},
);
});
it('should fallback to sw transcoding if hw transcoding fails', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
mediaMock.transcode.mockRejectedValueOnce(new Error('error'));
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledTimes(2);
expect(mediaMock.transcode).toHaveBeenLastCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
inputOptions: [],
outputOptions: [
'-vcodec h264',
'-acodec aac',
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
'-vf scale=-2:720',
'-preset ultrafast',
'-crf 23',
],
twoPass: false,
},
);
});
it('should return false for vaapi if no hw devices', async () => {
storageMock.readdir.mockResolvedValue([]);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toEqual(false);
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
}); });
}); });

View file

@ -1,4 +1,4 @@
import { AssetEntity, AssetType, TranscodePolicy, VideoCodec } from '@app/infra/entities'; import { AssetEntity, AssetType, TranscodeHWAccel, TranscodePolicy, VideoCodec } from '@app/infra/entities';
import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common'; import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common';
import { join } from 'path'; import { join } from 'path';
import { IAssetRepository, WithoutProperty } from '../asset'; import { IAssetRepository, WithoutProperty } from '../asset';
@ -8,8 +8,8 @@ import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config'; import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core'; import { SystemConfigCore } from '../system-config/system-config.core';
import { JPEG_THUMBNAIL_SIZE, WEBP_THUMBNAIL_SIZE } from './media.constant'; import { JPEG_THUMBNAIL_SIZE, WEBP_THUMBNAIL_SIZE } from './media.constant';
import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from './media.repository'; import { AudioStreamInfo, IMediaRepository, VideoCodecHWConfig, VideoStreamInfo } from './media.repository';
import { H264Config, HEVCConfig, VP9Config } from './media.util'; import { H264Config, HEVCConfig, NVENCConfig, QSVConfig, VAAPIConfig, VP9Config } from './media.util';
@Injectable() @Injectable()
export class MediaService { export class MediaService {
@ -155,14 +155,26 @@ export class MediaService {
let transcodeOptions; let transcodeOptions;
try { try {
transcodeOptions = this.getCodecConfig(config).getOptions(mainVideoStream); transcodeOptions = await this.getCodecConfig(config).then((c) => c.getOptions(mainVideoStream));
} catch (err) { } catch (err) {
this.logger.error(`An error occurred while configuring transcoding options: ${err}`); this.logger.error(`An error occurred while configuring transcoding options: ${err}`);
return false; return false;
} }
this.logger.log(`Start encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`); this.logger.log(`Start encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`);
await this.mediaRepository.transcode(input, output, transcodeOptions); try {
await this.mediaRepository.transcode(input, output, transcodeOptions);
} catch (err) {
this.logger.error(err);
if (config.accel && config.accel !== TranscodeHWAccel.DISABLED) {
this.logger.error(
`Error occurred during transcoding. Retrying with ${config.accel.toUpperCase()} acceleration disabled.`,
);
}
config.accel = TranscodeHWAccel.DISABLED;
transcodeOptions = await this.getCodecConfig(config).then((c) => c.getOptions(mainVideoStream));
await this.mediaRepository.transcode(input, output, transcodeOptions);
}
this.logger.log(`Encoding success ${asset.id}`); this.logger.log(`Encoding success ${asset.id}`);
@ -195,15 +207,11 @@ export class MediaService {
const isTargetContainer = ['mov,mp4,m4a,3gp,3g2,mj2', 'mp4', 'mov'].includes(containerExtension); const isTargetContainer = ['mov,mp4,m4a,3gp,3g2,mj2', 'mp4', 'mov'].includes(containerExtension);
const isTargetAudioCodec = audioStream == null || audioStream.codecName === ffmpegConfig.targetAudioCodec; const isTargetAudioCodec = audioStream == null || audioStream.codecName === ffmpegConfig.targetAudioCodec;
if (audioStream != null) { this.logger.verbose(
this.logger.verbose( `${asset.id}: AudioCodecName ${audioStream?.codecName ?? 'None'}, AudioStreamCodecType ${
`${asset.id}: AudioCodecName ${audioStream.codecName}, AudioStreamCodecType ${audioStream.codecType}, containerExtension ${containerExtension}`, audioStream?.codecType ?? 'None'
); }, containerExtension ${containerExtension}`,
} else { );
this.logger.verbose(
`${asset.id}: AudioCodecName None, AudioStreamCodecType None, containerExtension ${containerExtension}`,
);
}
const allTargetsMatching = isTargetVideoCodec && isTargetAudioCodec && isTargetContainer; const allTargetsMatching = isTargetVideoCodec && isTargetAudioCodec && isTargetContainer;
const scalingEnabled = ffmpegConfig.targetResolution !== 'original'; const scalingEnabled = ffmpegConfig.targetResolution !== 'original';
@ -228,7 +236,14 @@ export class MediaService {
} }
} }
private getCodecConfig(config: SystemConfigFFmpegDto) { async getCodecConfig(config: SystemConfigFFmpegDto) {
if (config.accel === TranscodeHWAccel.DISABLED) {
return this.getSWCodecConfig(config);
}
return this.getHWCodecConfig(config);
}
private getSWCodecConfig(config: SystemConfigFFmpegDto) {
switch (config.targetVideoCodec) { switch (config.targetVideoCodec) {
case VideoCodec.H264: case VideoCodec.H264:
return new H264Config(config); return new H264Config(config);
@ -240,4 +255,31 @@ export class MediaService {
throw new UnsupportedMediaTypeException(`Codec '${config.targetVideoCodec}' is unsupported`); throw new UnsupportedMediaTypeException(`Codec '${config.targetVideoCodec}' is unsupported`);
} }
} }
private async getHWCodecConfig(config: SystemConfigFFmpegDto) {
let handler: VideoCodecHWConfig;
let devices: string[];
switch (config.accel) {
case TranscodeHWAccel.NVENC:
handler = new NVENCConfig(config);
break;
case TranscodeHWAccel.QSV:
devices = await this.storageRepository.readdir('/dev/dri');
handler = new QSVConfig(config, devices);
break;
case TranscodeHWAccel.VAAPI:
devices = await this.storageRepository.readdir('/dev/dri');
handler = new VAAPIConfig(config, devices);
break;
default:
throw new UnsupportedMediaTypeException(`${config.accel.toUpperCase()} acceleration is unsupported`);
}
if (!handler.getSupportedCodecs().includes(config.targetVideoCodec)) {
throw new UnsupportedMediaTypeException(
`${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${handler.getSupportedCodecs()}`,
);
}
return handler;
}
} }

View file

@ -1,13 +1,26 @@
import { TranscodeHWAccel, VideoCodec } from '@app/infra/entities';
import { SystemConfigFFmpegDto } from '../system-config/dto'; import { SystemConfigFFmpegDto } from '../system-config/dto';
import { BitrateDistribution, TranscodeOptions, VideoCodecSWConfig, VideoStreamInfo } from './media.repository'; import {
BitrateDistribution,
TranscodeOptions,
VideoCodecHWConfig,
VideoCodecSWConfig,
VideoStreamInfo,
} from './media.repository';
class BaseConfig implements VideoCodecSWConfig { class BaseConfig implements VideoCodecSWConfig {
constructor(protected config: SystemConfigFFmpegDto) {} constructor(protected config: SystemConfigFFmpegDto) {}
getOptions(stream: VideoStreamInfo) { getOptions(stream: VideoStreamInfo) {
const options = { const options = {
inputOptions: this.getBaseInputOptions(), inputOptions: this.getBaseInputOptions(),
outputOptions: this.getBaseOutputOptions(), outputOptions: this.getBaseOutputOptions().concat([
`-acodec ${this.config.targetAudioCodec}`,
// Makes a second pass moving the moov atom to the
// beginning of the file for improved playback speed.
'-movflags faststart',
'-fps_mode passthrough',
'-v verbose',
]),
twoPass: this.eligibleForTwoPass(), twoPass: this.eligibleForTwoPass(),
} as TranscodeOptions; } as TranscodeOptions;
const filters = this.getFilterOptions(stream); const filters = this.getFilterOptions(stream);
@ -26,14 +39,7 @@ class BaseConfig implements VideoCodecSWConfig {
} }
getBaseOutputOptions() { getBaseOutputOptions() {
return [ return [`-vcodec ${this.config.targetVideoCodec}`];
`-vcodec ${this.config.targetVideoCodec}`,
`-acodec ${this.config.targetAudioCodec}`,
// Makes a second pass moving the moov atom to the beginning of
// the file for improved playback speed.
'-movflags faststart',
'-fps_mode passthrough',
];
} }
getFilterOptions(stream: VideoStreamInfo) { getFilterOptions(stream: VideoStreamInfo) {
@ -77,11 +83,11 @@ class BaseConfig implements VideoCodecSWConfig {
} }
eligibleForTwoPass() { eligibleForTwoPass() {
if (!this.config.twoPass) { if (!this.config.twoPass || this.config.accel !== TranscodeHWAccel.DISABLED) {
return false; return false;
} }
return this.isBitrateConstrained() || this.config.targetVideoCodec === 'vp9'; return this.isBitrateConstrained() || this.config.targetVideoCodec === VideoCodec.VP9;
} }
getBitrateDistribution() { getBitrateDistribution() {
@ -107,7 +113,8 @@ class BaseConfig implements VideoCodecSWConfig {
getScaling(stream: VideoStreamInfo) { getScaling(stream: VideoStreamInfo) {
const targetResolution = this.getTargetResolution(stream); const targetResolution = this.getTargetResolution(stream);
return this.isVideoVertical(stream) ? `${targetResolution}:-2` : `-2:${targetResolution}`; const mult = this.config.accel === TranscodeHWAccel.QSV ? 1 : 2; // QSV doesn't support scaling numbers below -1
return this.isVideoVertical(stream) ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
} }
isVideoRotated(stream: VideoStreamInfo) { isVideoRotated(stream: VideoStreamInfo) {
@ -137,6 +144,34 @@ class BaseConfig implements VideoCodecSWConfig {
} }
} }
export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
protected devices: string[];
constructor(protected config: SystemConfigFFmpegDto, devices: string[] = []) {
super(config);
this.devices = this.validateDevices(devices);
}
getSupportedCodecs() {
return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9];
}
validateDevices(devices: string[]) {
return devices
.filter((device) => device.startsWith('renderD') || device.startsWith('card'))
.sort((a, b) => {
// order GPU devices first
if (a.startsWith('card') && b.startsWith('renderD')) {
return -1;
}
if (a.startsWith('renderD') && b.startsWith('card')) {
return 1;
}
return -a.localeCompare(b);
});
}
}
export class H264Config extends BaseConfig { export class H264Config extends BaseConfig {
getThreadOptions() { getThreadOptions() {
if (this.config.threads <= 0) { if (this.config.threads <= 0) {
@ -189,3 +224,168 @@ export class VP9Config extends BaseConfig {
return ['-row-mt 1', ...super.getThreadOptions()]; return ['-row-mt 1', ...super.getThreadOptions()];
} }
} }
export class NVENCConfig extends BaseHWConfig {
getSupportedCodecs() {
return [VideoCodec.H264, VideoCodec.HEVC];
}
getBaseInputOptions() {
return ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'];
}
getBaseOutputOptions() {
return [
`-vcodec ${this.config.targetVideoCodec}_nvenc`,
// below settings recommended from https://docs.nvidia.com/video-technologies/video-codec-sdk/12.0/ffmpeg-with-nvidia-gpu/index.html#command-line-for-latency-tolerant-high-quality-transcoding
'-tune hq',
'-qmin 0',
'-g 250',
'-bf 3',
'-b_ref_mode middle',
'-temporal-aq 1',
'-rc-lookahead 20',
'-i_qfactor 0.75',
'-b_qfactor 1.1',
];
}
getFilterOptions(stream: VideoStreamInfo) {
const options = ['hwupload_cuda'];
if (this.shouldScale(stream)) {
options.push(`scale_cuda=${this.getScaling(stream)}`);
}
return options;
}
getPresetOptions() {
let presetIndex = this.getPresetIndex();
if (presetIndex < 0) {
return [];
}
presetIndex = 7 - Math.min(6, presetIndex); // map to p1-p7; p7 is the highest quality, so reverse index
return [`-preset p${presetIndex}`];
}
getBitrateOptions() {
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0 && this.config.twoPass) {
return [
`-b:v ${bitrates.target}${bitrates.unit}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-bufsize ${bitrates.target}${bitrates.unit}`,
'-multipass 2',
];
} else if (bitrates.max > 0) {
return [
`-cq:v ${this.config.crf}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-bufsize ${bitrates.target}${bitrates.unit}`,
];
} else {
return [`-cq:v ${this.config.crf}`];
}
}
getThreadOptions() {
return [];
}
}
export class QSVConfig extends BaseHWConfig {
getBaseInputOptions() {
if (!this.devices.length) {
throw Error('No QSV device found');
}
return ['-init_hw_device qsv=hw', '-filter_hw_device hw'];
}
getBaseOutputOptions() {
// recommended from https://github.com/intel/media-delivery/blob/master/doc/benchmarks/intel-iris-xe-max-graphics/intel-iris-xe-max-graphics.md
const options = [`-vcodec ${this.config.targetVideoCodec}_qsv`, '-g 256', '-extbrc 1', '-refs 5', '-bf 7'];
// VP9 requires enabling low power mode https://git.ffmpeg.org/gitweb/ffmpeg.git/commit/33583803e107b6d532def0f9d949364b01b6ad5a
if (this.config.targetVideoCodec === VideoCodec.VP9) {
options.push('-low_power 1');
}
return options;
}
getFilterOptions(stream: VideoStreamInfo) {
const options = ['format=nv12', 'hwupload=extra_hw_frames=64'];
if (this.shouldScale(stream)) {
options.push(`scale_qsv=${this.getScaling(stream)}`);
}
return options;
}
getPresetOptions() {
let presetIndex = this.getPresetIndex();
if (presetIndex < 0) {
return [];
}
presetIndex = Math.min(6, presetIndex) + 1; // 1 to 7
return [`-preset ${presetIndex}`];
}
getBitrateOptions() {
const options = [];
if (this.config.targetVideoCodec !== VideoCodec.VP9) {
options.push(`-global_quality ${this.config.crf}`);
} else {
options.push(`-q:v ${this.config.crf}`);
}
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0) {
options.push(`-maxrate ${bitrates.max}${bitrates.unit}`);
options.push(`-bufsize ${bitrates.max * 2}${bitrates.unit}`);
}
return options;
}
}
export class VAAPIConfig extends BaseHWConfig {
getBaseInputOptions() {
if (this.devices.length === 0) {
throw Error('No VAAPI device found');
}
return [`-init_hw_device vaapi=accel:/dev/dri/${this.devices[0]}`, '-filter_hw_device accel'];
}
getBaseOutputOptions() {
return [`-vcodec ${this.config.targetVideoCodec}_vaapi`];
}
getFilterOptions(stream: VideoStreamInfo) {
const options = ['format=nv12', 'hwupload'];
if (this.shouldScale(stream)) {
options.push(`scale_vaapi=${this.getScaling(stream)}`);
}
return options;
}
getPresetOptions() {
let presetIndex = this.getPresetIndex();
if (presetIndex < 0) {
return [];
}
presetIndex = Math.min(6, presetIndex) + 1; // 1 to 7
return [`-compression_level ${presetIndex}`];
}
getBitrateOptions() {
const bitrates = this.getBitrateDistribution();
// VAAPI doesn't allow setting both quality and max bitrate
if (bitrates.max > 0) {
return [
`-b:v ${bitrates.target}${bitrates.unit}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-minrate ${bitrates.min}${bitrates.unit}`,
'-rc_mode 3',
]; // variable bitrate
} else {
return [`-qp ${this.config.crf}`, `-global_quality ${this.config.crf}`, '-rc_mode 1']; // constant quality
}
}
}

View file

@ -29,4 +29,5 @@ export interface IStorageRepository {
checkFileExists(filepath: string, mode?: number): Promise<boolean>; checkFileExists(filepath: string, mode?: number): Promise<boolean>;
mkdirSync(filepath: string): void; mkdirSync(filepath: string): void;
checkDiskUsage(folder: string): Promise<DiskUsage>; checkDiskUsage(folder: string): Promise<DiskUsage>;
readdir(folder: string): Promise<string[]>;
} }

View file

@ -1,4 +1,4 @@
import { AudioCodec, TranscodePolicy, VideoCodec } from '@app/infra/entities'; import { AudioCodec, TranscodeHWAccel, TranscodePolicy, VideoCodec } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsInt, IsString, Max, Min } from 'class-validator'; import { IsBoolean, IsEnum, IsInt, IsString, Max, Min } from 'class-validator';
@ -40,4 +40,8 @@ export class SystemConfigFFmpegDto {
@IsEnum(TranscodePolicy) @IsEnum(TranscodePolicy)
@ApiProperty({ enumName: 'TranscodePolicy', enum: TranscodePolicy }) @ApiProperty({ enumName: 'TranscodePolicy', enum: TranscodePolicy })
transcode!: TranscodePolicy; transcode!: TranscodePolicy;
@IsEnum(TranscodeHWAccel)
@ApiProperty({ enumName: 'TranscodeHWAccel', enum: TranscodeHWAccel })
accel!: TranscodeHWAccel;
} }

View file

@ -4,6 +4,7 @@ import {
SystemConfigEntity, SystemConfigEntity,
SystemConfigKey, SystemConfigKey,
SystemConfigValue, SystemConfigValue,
TranscodeHWAccel,
TranscodePolicy, TranscodePolicy,
VideoCodec, VideoCodec,
} from '@app/infra/entities'; } from '@app/infra/entities';
@ -27,6 +28,7 @@ export const defaults = Object.freeze<SystemConfig>({
maxBitrate: '0', maxBitrate: '0',
twoPass: false, twoPass: false,
transcode: TranscodePolicy.REQUIRED, transcode: TranscodePolicy.REQUIRED,
accel: TranscodeHWAccel.DISABLED,
}, },
job: { job: {
[QueueName.BACKGROUND_TASK]: { concurrency: 5 }, [QueueName.BACKGROUND_TASK]: { concurrency: 5 },

View file

@ -3,6 +3,7 @@ import {
SystemConfig, SystemConfig,
SystemConfigEntity, SystemConfigEntity,
SystemConfigKey, SystemConfigKey,
TranscodeHWAccel,
TranscodePolicy, TranscodePolicy,
VideoCodec, VideoCodec,
} from '@app/infra/entities'; } from '@app/infra/entities';
@ -41,6 +42,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
maxBitrate: '0', maxBitrate: '0',
twoPass: false, twoPass: false,
transcode: TranscodePolicy.REQUIRED, transcode: TranscodePolicy.REQUIRED,
accel: TranscodeHWAccel.DISABLED,
}, },
oauth: { oauth: {
autoLaunch: true, autoLaunch: true,

View file

@ -23,6 +23,7 @@ export enum SystemConfigKey {
FFMPEG_MAX_BITRATE = 'ffmpeg.maxBitrate', FFMPEG_MAX_BITRATE = 'ffmpeg.maxBitrate',
FFMPEG_TWO_PASS = 'ffmpeg.twoPass', FFMPEG_TWO_PASS = 'ffmpeg.twoPass',
FFMPEG_TRANSCODE = 'ffmpeg.transcode', FFMPEG_TRANSCODE = 'ffmpeg.transcode',
FFMPEG_ACCEL = 'ffmpeg.accel',
JOB_THUMBNAIL_GENERATION_CONCURRENCY = 'job.thumbnailGeneration.concurrency', JOB_THUMBNAIL_GENERATION_CONCURRENCY = 'job.thumbnailGeneration.concurrency',
JOB_METADATA_EXTRACTION_CONCURRENCY = 'job.metadataExtraction.concurrency', JOB_METADATA_EXTRACTION_CONCURRENCY = 'job.metadataExtraction.concurrency',
@ -71,6 +72,13 @@ export enum AudioCodec {
OPUS = 'opus', OPUS = 'opus',
} }
export enum TranscodeHWAccel {
NVENC = 'nvenc',
QSV = 'qsv',
VAAPI = 'vaapi',
DISABLED = 'disabled',
}
export interface SystemConfig { export interface SystemConfig {
ffmpeg: { ffmpeg: {
crf: number; crf: number;
@ -82,6 +90,7 @@ export interface SystemConfig {
maxBitrate: string; maxBitrate: string;
twoPass: boolean; twoPass: boolean;
transcode: TranscodePolicy; transcode: TranscodePolicy;
accel: TranscodeHWAccel;
}; };
job: Record<QueueName, { concurrency: number }>; job: Record<QueueName, { concurrency: number }>;
oauth: { oauth: {

View file

@ -1,7 +1,7 @@
import { DiskUsage, ImmichReadStream, ImmichZipStream, IStorageRepository } from '@app/domain'; import { DiskUsage, ImmichReadStream, ImmichZipStream, IStorageRepository } from '@app/domain';
import archiver from 'archiver'; import archiver from 'archiver';
import { constants, createReadStream, existsSync, mkdirSync } from 'fs'; import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
import fs from 'fs/promises'; import fs, { readdir } from 'fs/promises';
import mv from 'mv'; import mv from 'mv';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import path from 'path'; import path from 'path';
@ -92,4 +92,6 @@ export class FilesystemProvider implements IStorageRepository {
total: stats.blocks * stats.bsize, total: stats.blocks * stats.bsize,
}; };
} }
readdir = readdir;
} }

View file

@ -6,6 +6,7 @@ import sharp from 'sharp';
import { promisify } from 'util'; import { promisify } from 'util';
const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe); const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
sharp.concurrency(0);
export class MediaRepository implements IMediaRepository { export class MediaRepository implements IMediaRepository {
private logger = new Logger(MediaRepository.name); private logger = new Logger(MediaRepository.name);
@ -73,7 +74,7 @@ export class MediaRepository implements IMediaRepository {
.map((stream) => ({ .map((stream) => ({
height: stream.height || 0, height: stream.height || 0,
width: stream.width || 0, width: stream.width || 0,
codecName: stream.codec_name, codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
codecType: stream.codec_type, codecType: stream.codec_type,
frameCount: Number.parseInt(stream.nb_frames ?? '0'), frameCount: Number.parseInt(stream.nb_frames ?? '0'),
rotation: Number.parseInt(`${stream.rotation ?? 0}`), rotation: Number.parseInt(`${stream.rotation ?? 0}`),
@ -91,6 +92,7 @@ export class MediaRepository implements IMediaRepository {
if (!options.twoPass) { if (!options.twoPass) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ffmpeg(input, { niceness: 10 }) ffmpeg(input, { niceness: 10 })
.inputOptions(options.inputOptions)
.outputOptions(options.outputOptions) .outputOptions(options.outputOptions)
.output(output) .output(output)
.on('error', (err, stdout, stderr) => { .on('error', (err, stdout, stderr) => {
@ -106,6 +108,7 @@ export class MediaRepository implements IMediaRepository {
// recommended for vp9 for better quality and compression // recommended for vp9 for better quality and compression
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ffmpeg(input, { niceness: 10 }) ffmpeg(input, { niceness: 10 })
.inputOptions(options.inputOptions)
.outputOptions(options.outputOptions) .outputOptions(options.outputOptions)
.addOptions('-pass', '1') .addOptions('-pass', '1')
.addOptions('-passlogfile', output) .addOptions('-passlogfile', output)
@ -118,6 +121,7 @@ export class MediaRepository implements IMediaRepository {
.on('end', () => { .on('end', () => {
// second pass // second pass
ffmpeg(input, { niceness: 10 }) ffmpeg(input, { niceness: 10 })
.inputOptions(options.inputOptions)
.outputOptions(options.outputOptions) .outputOptions(options.outputOptions)
.addOptions('-pass', '2') .addOptions('-pass', '2')
.addOptions('-passlogfile', output) .addOptions('-passlogfile', output)

View file

@ -1,5 +1,7 @@
#!/bin/sh #!/bin/sh
export LD_PRELOAD=/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2
if [ "$DB_URL_FILE" ]; then if [ "$DB_URL_FILE" ]; then
export DB_URL=$(cat $DB_URL_FILE) export DB_URL=$(cat $DB_URL_FILE)
unset DB_URL_FILE unset DB_URL_FILE

View file

@ -7,7 +7,7 @@ const probeStubDefaultFormat: VideoFormat = {
}; };
const probeStubDefaultVideoStream: VideoStreamInfo[] = [ const probeStubDefaultVideoStream: VideoStreamInfo[] = [
{ height: 1080, width: 1920, codecName: 'h265', codecType: 'video', frameCount: 100, rotation: 0 }, { height: 1080, width: 1920, codecName: 'hevc', codecType: 'video', frameCount: 100, rotation: 0 },
]; ];
const probeStubDefaultAudioStream: AudioStreamInfo[] = [{ codecName: 'aac', codecType: 'audio' }]; const probeStubDefaultAudioStream: AudioStreamInfo[] = [{ codecName: 'aac', codecType: 'audio' }];
@ -20,13 +20,14 @@ const probeStubDefault: VideoInfo = {
export const probeStub = { export const probeStub = {
noVideoStreams: Object.freeze<VideoInfo>({ ...probeStubDefault, videoStreams: [] }), noVideoStreams: Object.freeze<VideoInfo>({ ...probeStubDefault, videoStreams: [] }),
noAudioStreams: Object.freeze<VideoInfo>({ ...probeStubDefault, audioStreams: [] }),
multipleVideoStreams: Object.freeze<VideoInfo>({ multipleVideoStreams: Object.freeze<VideoInfo>({
...probeStubDefault, ...probeStubDefault,
videoStreams: [ videoStreams: [
{ {
height: 1080, height: 1080,
width: 400, width: 400,
codecName: 'h265', codecName: 'hevc',
codecType: 'video', codecType: 'video',
frameCount: 100, frameCount: 100,
rotation: 0, rotation: 0,
@ -47,7 +48,7 @@ export const probeStub = {
{ {
height: 0, height: 0,
width: 400, width: 400,
codecName: 'h265', codecName: 'hevc',
codecType: 'video', codecType: 'video',
frameCount: 100, frameCount: 100,
rotation: 0, rotation: 0,

View file

@ -11,5 +11,6 @@ export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
checkFileExists: jest.fn(), checkFileExists: jest.fn(),
mkdirSync: jest.fn(), mkdirSync: jest.fn(),
checkDiskUsage: jest.fn(), checkDiskUsage: jest.fn(),
readdir: jest.fn(),
}; };
}; };

View file

@ -666,13 +666,13 @@ export interface AssetStatsResponseDto {
* @type {number} * @type {number}
* @memberof AssetStatsResponseDto * @memberof AssetStatsResponseDto
*/ */
'total': number; 'videos': number;
/** /**
* *
* @type {number} * @type {number}
* @memberof AssetStatsResponseDto * @memberof AssetStatsResponseDto
*/ */
'videos': number; 'total': number;
} }
/** /**
* *
@ -2510,6 +2510,12 @@ export interface SystemConfigDto {
* @interface SystemConfigFFmpegDto * @interface SystemConfigFFmpegDto
*/ */
export interface SystemConfigFFmpegDto { export interface SystemConfigFFmpegDto {
/**
*
* @type {TranscodeHWAccel}
* @memberof SystemConfigFFmpegDto
*/
'accel': TranscodeHWAccel;
/** /**
* *
* @type {number} * @type {number}
@ -2858,6 +2864,22 @@ export const TimeGroupEnum = {
export type TimeGroupEnum = typeof TimeGroupEnum[keyof typeof TimeGroupEnum]; export type TimeGroupEnum = typeof TimeGroupEnum[keyof typeof TimeGroupEnum];
/**
*
* @export
* @enum {string}
*/
export const TranscodeHWAccel = {
Nvenc: 'nvenc',
Qsv: 'qsv',
Vaapi: 'vaapi',
Disabled: 'disabled'
} as const;
export type TranscodeHWAccel = typeof TranscodeHWAccel[keyof typeof TranscodeHWAccel];
/** /**
* *
* @export * @export

View file

@ -3,7 +3,7 @@
notificationController, notificationController,
NotificationType, NotificationType,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { api, AudioCodec, SystemConfigFFmpegDto, TranscodePolicy, VideoCodec } from '@api'; import { api, AudioCodec, SystemConfigFFmpegDto, TranscodeHWAccel, TranscodePolicy, VideoCodec } from '@api';
import SettingButtonsRow from '../setting-buttons-row.svelte'; import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSelect from '../setting-select.svelte'; import SettingSelect from '../setting-select.svelte';
@ -189,6 +189,29 @@
isEdited={!(ffmpegConfig.transcode == savedConfig.transcode)} isEdited={!(ffmpegConfig.transcode == savedConfig.transcode)}
/> />
<SettingSelect
label="HARDWARE ACCELERATION"
desc="Experimental. Much faster, but will have lower quality at the same bitrate. This setting is 'best effort': it will fallback to software transcoding on failure. VP9 may or may not work depending on your hardware."
bind:value={ffmpegConfig.accel}
name="accel"
options={[
{ value: TranscodeHWAccel.Nvenc, text: 'NVENC (requires NVIDIA GPU)' },
{
value: TranscodeHWAccel.Qsv,
text: 'Quick Sync (requires 7th gen Intel CPU or later)',
},
{
value: TranscodeHWAccel.Vaapi,
text: 'VAAPI',
},
{
value: TranscodeHWAccel.Disabled,
text: 'Disabled',
},
]}
isEdited={!(ffmpegConfig.accel == savedConfig.accel)}
/>
<SettingSwitch <SettingSwitch
title="TWO-PASS ENCODING" title="TWO-PASS ENCODING"
subtitle="Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled." subtitle="Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled."