mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00:00
feat(server): hardware video acceleration for Rockchip SOCs via RKMPP (#4645)
* feat(server): hardware video acceleration for Rockchip SOCs via RKMPP * add tests * use LD_LIBRARY_PATH for custom ffmpeg * incorporate review feedback * code re-use for ffmpeg call * review feedback
This commit is contained in:
parent
c54a188154
commit
ce04e9e07a
11 changed files with 227 additions and 27 deletions
24
docker/hwaccel-rkmpp.yml
Normal file
24
docker/hwaccel-rkmpp.yml
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
# Hardware acceleration for transcoding using RKMPP for Rockchip SOCs
|
||||||
|
# This is only needed if you want to use hardware acceleration for transcoding.
|
||||||
|
# Supported host OS is Ubuntu Jammy 22.04 with custom ffmpeg from ppa:liujianfeng1994/rockchip-multimedia
|
||||||
|
|
||||||
|
services:
|
||||||
|
hwaccel:
|
||||||
|
security_opt: # enables full access to /sys and /proc, still far better than privileged: true
|
||||||
|
- systempaths=unconfined
|
||||||
|
- apparmor=unconfined
|
||||||
|
group_add:
|
||||||
|
- video
|
||||||
|
devices:
|
||||||
|
- /dev/rga:/dev/rga
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
- /dev/dma_heap:/dev/dma_heap
|
||||||
|
- /dev/mpp_service:/dev/mpp_service
|
||||||
|
volumes:
|
||||||
|
- /usr/bin/ffmpeg:/usr/bin/ffmpeg_mpp:ro
|
||||||
|
- /lib/aarch64-linux-gnu:/lib/ffmpeg-mpp:ro
|
||||||
|
- /lib/aarch64-linux-gnu/libblas.so.3:/lib/ffmpeg-mpp/libblas.so.3:ro # symlink is resolved by mounting
|
||||||
|
- /lib/aarch64-linux-gnu/liblapack.so.3:/lib/ffmpeg-mpp/liblapack.so.3:ro # symlink is resolved by mounting
|
||||||
|
- /lib/aarch64-linux-gnu/pulseaudio/libpulsecommon-15.99.so:/lib/ffmpeg-mpp/libpulsecommon-15.99.so:ro
|
BIN
mobile/openapi/lib/model/transcode_hw_accel.dart
generated
BIN
mobile/openapi/lib/model/transcode_hw_accel.dart
generated
Binary file not shown.
|
@ -8607,6 +8607,7 @@
|
||||||
"nvenc",
|
"nvenc",
|
||||||
"qsv",
|
"qsv",
|
||||||
"vaapi",
|
"vaapi",
|
||||||
|
"rkmpp",
|
||||||
"disabled"
|
"disabled"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|
|
@ -1508,6 +1508,83 @@ describe(MediaService.name, () => {
|
||||||
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toEqual(false);
|
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toEqual(false);
|
||||||
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set vbr options for rkmpp when max bitrate is enabled', async () => {
|
||||||
|
storageMock.readdir.mockResolvedValue(['renderD128']);
|
||||||
|
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
|
configMock.load.mockResolvedValue([
|
||||||
|
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP },
|
||||||
|
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' },
|
||||||
|
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
|
||||||
|
]);
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||||
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
|
'/original/path.ext',
|
||||||
|
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||||
|
{
|
||||||
|
inputOptions: [],
|
||||||
|
outputOptions: [
|
||||||
|
`-c:v hevc_rkmpp_encoder`,
|
||||||
|
'-c:a aac',
|
||||||
|
'-movflags faststart',
|
||||||
|
'-fps_mode passthrough',
|
||||||
|
'-map 0:0',
|
||||||
|
'-map 0:1',
|
||||||
|
'-g 256',
|
||||||
|
'-v verbose',
|
||||||
|
'-level 153',
|
||||||
|
'-rc_mode 3',
|
||||||
|
'-quality_min 0',
|
||||||
|
'-quality_max 100',
|
||||||
|
'-b:v 10000k',
|
||||||
|
'-width 1280',
|
||||||
|
'-height 720',
|
||||||
|
],
|
||||||
|
twoPass: false,
|
||||||
|
ffmpegPath: 'ffmpeg_mpp',
|
||||||
|
ldLibraryPath: '/lib/aarch64-linux-gnu:/lib/ffmpeg-mpp',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set cqp options for rkmpp when max bitrate is disabled', async () => {
|
||||||
|
storageMock.readdir.mockResolvedValue(['renderD128']);
|
||||||
|
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
|
configMock.load.mockResolvedValue([
|
||||||
|
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP },
|
||||||
|
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 },
|
||||||
|
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '0' },
|
||||||
|
]);
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||||
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
|
'/original/path.ext',
|
||||||
|
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||||
|
{
|
||||||
|
inputOptions: [],
|
||||||
|
outputOptions: [
|
||||||
|
`-c:v h264_rkmpp_encoder`,
|
||||||
|
'-c:a aac',
|
||||||
|
'-movflags faststart',
|
||||||
|
'-fps_mode passthrough',
|
||||||
|
'-map 0:0',
|
||||||
|
'-map 0:1',
|
||||||
|
'-g 256',
|
||||||
|
'-v verbose',
|
||||||
|
'-level 51',
|
||||||
|
'-rc_mode 2',
|
||||||
|
'-quality_min 51',
|
||||||
|
'-quality_max 51',
|
||||||
|
'-width 1280',
|
||||||
|
'-height 720',
|
||||||
|
],
|
||||||
|
twoPass: false,
|
||||||
|
ffmpegPath: 'ffmpeg_mpp',
|
||||||
|
ldLibraryPath: '/lib/aarch64-linux-gnu:/lib/ffmpeg-mpp',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should tonemap when policy is required and video is hdr', async () => {
|
it('should tonemap when policy is required and video is hdr', async () => {
|
||||||
|
|
|
@ -26,7 +26,16 @@ import {
|
||||||
import { StorageCore, StorageFolder } from '../storage';
|
import { StorageCore, StorageFolder } from '../storage';
|
||||||
import { SystemConfigFFmpegDto } from '../system-config';
|
import { SystemConfigFFmpegDto } from '../system-config';
|
||||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||||
import { H264Config, HEVCConfig, NVENCConfig, QSVConfig, ThumbnailConfig, VAAPIConfig, VP9Config } from './media.util';
|
import {
|
||||||
|
H264Config,
|
||||||
|
HEVCConfig,
|
||||||
|
NVENCConfig,
|
||||||
|
QSVConfig,
|
||||||
|
RKMPPConfig,
|
||||||
|
ThumbnailConfig,
|
||||||
|
VAAPIConfig,
|
||||||
|
VP9Config,
|
||||||
|
} from './media.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MediaService {
|
export class MediaService {
|
||||||
|
@ -352,6 +361,10 @@ export class MediaService {
|
||||||
devices = await this.storageRepository.readdir('/dev/dri');
|
devices = await this.storageRepository.readdir('/dev/dri');
|
||||||
handler = new VAAPIConfig(config, devices);
|
handler = new VAAPIConfig(config, devices);
|
||||||
break;
|
break;
|
||||||
|
case TranscodeHWAccel.RKMPP:
|
||||||
|
devices = await this.storageRepository.readdir('/dev/dri');
|
||||||
|
handler = new RKMPPConfig(config, devices);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new UnsupportedMediaTypeException(`${config.accel.toUpperCase()} acceleration is unsupported`);
|
throw new UnsupportedMediaTypeException(`${config.accel.toUpperCase()} acceleration is unsupported`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -143,6 +143,13 @@ class BaseConfig implements VideoCodecSWConfig {
|
||||||
return this.isVideoVertical(videoStream) ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
|
return this.isVideoVertical(videoStream) ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSize(videoStream: VideoStreamInfo) {
|
||||||
|
const smaller = this.getTargetResolution(videoStream);
|
||||||
|
const factor = Math.max(videoStream.height, videoStream.width) / Math.min(videoStream.height, videoStream.width);
|
||||||
|
const larger = Math.round(smaller * factor);
|
||||||
|
return this.isVideoVertical(videoStream) ? { width: smaller, height: larger } : { width: larger, height: smaller };
|
||||||
|
}
|
||||||
|
|
||||||
isVideoRotated(videoStream: VideoStreamInfo) {
|
isVideoRotated(videoStream: VideoStreamInfo) {
|
||||||
return Math.abs(videoStream.rotation) === 90;
|
return Math.abs(videoStream.rotation) === 90;
|
||||||
}
|
}
|
||||||
|
@ -555,3 +562,68 @@ export class VAAPIConfig extends BaseHWConfig {
|
||||||
return this.config.cqMode !== CQMode.ICQ || this.config.targetVideoCodec === VideoCodec.VP9;
|
return this.config.cqMode !== CQMode.ICQ || this.config.targetVideoCodec === VideoCodec.VP9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RKMPPConfig extends BaseHWConfig {
|
||||||
|
getOptions(videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo): TranscodeOptions {
|
||||||
|
const options = super.getOptions(videoStream, audioStream);
|
||||||
|
options.ffmpegPath = 'ffmpeg_mpp';
|
||||||
|
options.ldLibraryPath = '/lib/aarch64-linux-gnu:/lib/ffmpeg-mpp';
|
||||||
|
options.outputOptions.push(...this.getSizeOptions(videoStream));
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
eligibleForTwoPass(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBaseInputOptions() {
|
||||||
|
if (this.devices.length === 0) {
|
||||||
|
throw Error('No RKMPP device found');
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getFilterOptions(videoStream: VideoStreamInfo) {
|
||||||
|
return this.shouldToneMap(videoStream) ? this.getToneMapping() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getSizeOptions(videoStream: VideoStreamInfo) {
|
||||||
|
if (this.shouldScale(videoStream)) {
|
||||||
|
const { width, height } = this.getSize(videoStream);
|
||||||
|
return [`-width ${width}`, `-height ${height}`];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getPresetOptions() {
|
||||||
|
switch (this.config.targetVideoCodec) {
|
||||||
|
case VideoCodec.H264:
|
||||||
|
// from ffmpeg_mpp help, commonly referred to as H264 level 5.1
|
||||||
|
return ['-level 51'];
|
||||||
|
case VideoCodec.HEVC:
|
||||||
|
// from ffmpeg_mpp help, commonly referred to as HEVC level 5.1
|
||||||
|
return ['-level 153'];
|
||||||
|
default:
|
||||||
|
throw Error(`Incompatible video codec for RKMPP: ${this.config.targetVideoCodec}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getBitrateOptions() {
|
||||||
|
const bitrate = this.getMaxBitrateValue();
|
||||||
|
if (bitrate > 0) {
|
||||||
|
return ['-rc_mode 3', '-quality_min 0', '-quality_max 100', `-b:v ${bitrate}${this.getBitrateUnit()}`];
|
||||||
|
} else {
|
||||||
|
// convert CQP from 51-10 to 0-100, values below 10 are set to 10
|
||||||
|
const quality = Math.floor(125 - Math.max(this.config.crf, 10) * (125 / 51));
|
||||||
|
return ['-rc_mode 2', `-quality_min ${quality}`, `-quality_max ${quality}`];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSupportedCodecs() {
|
||||||
|
return [VideoCodec.H264, VideoCodec.HEVC];
|
||||||
|
}
|
||||||
|
|
||||||
|
getVideoCodec(): string {
|
||||||
|
return `${this.config.targetVideoCodec}_rkmpp_encoder`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -51,6 +51,8 @@ export interface TranscodeOptions {
|
||||||
inputOptions: string[];
|
inputOptions: string[];
|
||||||
outputOptions: string[];
|
outputOptions: string[];
|
||||||
twoPass: boolean;
|
twoPass: boolean;
|
||||||
|
ffmpegPath?: string;
|
||||||
|
ldLibraryPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BitrateDistribution {
|
export interface BitrateDistribution {
|
||||||
|
|
|
@ -119,6 +119,7 @@ export enum TranscodeHWAccel {
|
||||||
NVENC = 'nvenc',
|
NVENC = 'nvenc',
|
||||||
QSV = 'qsv',
|
QSV = 'qsv',
|
||||||
VAAPI = 'vaapi',
|
VAAPI = 'vaapi',
|
||||||
|
RKMPP = 'rkmpp',
|
||||||
DISABLED = 'disabled',
|
DISABLED = 'disabled',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -70,16 +70,18 @@ export class MediaRepository implements IMediaRepository {
|
||||||
transcode(input: string, output: string | Writable, options: TranscodeOptions): Promise<void> {
|
transcode(input: string, output: string | Writable, options: TranscodeOptions): Promise<void> {
|
||||||
if (!options.twoPass) {
|
if (!options.twoPass) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
ffmpeg(input, { niceness: 10 })
|
const oldLdLibraryPath = process.env.LD_LIBRARY_PATH;
|
||||||
.inputOptions(options.inputOptions)
|
if (options.ldLibraryPath) {
|
||||||
.outputOptions(options.outputOptions)
|
// fluent ffmpeg does not allow to set environment variables, so we do it manually
|
||||||
.output(output)
|
process.env.LD_LIBRARY_PATH = this.chainPath(oldLdLibraryPath || '', options.ldLibraryPath);
|
||||||
.on('error', (err, stdout, stderr) => {
|
}
|
||||||
this.logger.error(stderr);
|
try {
|
||||||
reject(err);
|
this.configureFfmpegCall(input, output, options).on('error', reject).on('end', resolve).run();
|
||||||
})
|
} finally {
|
||||||
.on('end', resolve)
|
if (options.ldLibraryPath) {
|
||||||
.run();
|
process.env.LD_LIBRARY_PATH = oldLdLibraryPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -90,29 +92,18 @@ export class MediaRepository implements IMediaRepository {
|
||||||
// two-pass allows for precise control of bitrate at the cost of running twice
|
// two-pass allows for precise control of bitrate at the cost of running twice
|
||||||
// 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 })
|
// first pass output is not saved as only the .log file is needed
|
||||||
.inputOptions(options.inputOptions)
|
this.configureFfmpegCall(input, '/dev/null', options)
|
||||||
.outputOptions(options.outputOptions)
|
|
||||||
.addOptions('-pass', '1')
|
.addOptions('-pass', '1')
|
||||||
.addOptions('-passlogfile', output)
|
.addOptions('-passlogfile', output)
|
||||||
.addOptions('-f null')
|
.addOptions('-f null')
|
||||||
.output('/dev/null') // first pass output is not saved as only the .log file is needed
|
.on('error', reject)
|
||||||
.on('error', (err, stdout, stderr) => {
|
|
||||||
this.logger.error(stderr);
|
|
||||||
reject(err);
|
|
||||||
})
|
|
||||||
.on('end', () => {
|
.on('end', () => {
|
||||||
// second pass
|
// second pass
|
||||||
ffmpeg(input, { niceness: 10 })
|
this.configureFfmpegCall(input, output, options)
|
||||||
.inputOptions(options.inputOptions)
|
|
||||||
.outputOptions(options.outputOptions)
|
|
||||||
.addOptions('-pass', '2')
|
.addOptions('-pass', '2')
|
||||||
.addOptions('-passlogfile', output)
|
.addOptions('-passlogfile', output)
|
||||||
.output(output)
|
.on('error', reject)
|
||||||
.on('error', (err, stdout, stderr) => {
|
|
||||||
this.logger.error(stderr);
|
|
||||||
reject(err);
|
|
||||||
})
|
|
||||||
.on('end', () => fs.unlink(`${output}-0.log`))
|
.on('end', () => fs.unlink(`${output}-0.log`))
|
||||||
.on('end', () => fs.rm(`${output}-0.log.mbtree`, { force: true }))
|
.on('end', () => fs.rm(`${output}-0.log.mbtree`, { force: true }))
|
||||||
.on('end', resolve)
|
.on('end', resolve)
|
||||||
|
@ -122,6 +113,20 @@ export class MediaRepository implements IMediaRepository {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configureFfmpegCall(input: string, output: string | Writable, options: TranscodeOptions) {
|
||||||
|
return ffmpeg(input, { niceness: 10 })
|
||||||
|
.setFfmpegPath(options.ffmpegPath || 'ffmpeg')
|
||||||
|
.inputOptions(options.inputOptions)
|
||||||
|
.outputOptions(options.outputOptions)
|
||||||
|
.output(output)
|
||||||
|
.on('error', (err, stdout, stderr) => this.logger.error(stderr || err));
|
||||||
|
}
|
||||||
|
|
||||||
|
chainPath(existing: string, path: string) {
|
||||||
|
const sep = existing.endsWith(':') ? '' : ':';
|
||||||
|
return `${existing}${sep}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
async generateThumbhash(imagePath: string): Promise<Buffer> {
|
async generateThumbhash(imagePath: string): Promise<Buffer> {
|
||||||
const maxSize = 100;
|
const maxSize = 100;
|
||||||
|
|
||||||
|
|
1
web/src/api/open-api/api.ts
generated
1
web/src/api/open-api/api.ts
generated
|
@ -3964,6 +3964,7 @@ export const TranscodeHWAccel = {
|
||||||
Nvenc: 'nvenc',
|
Nvenc: 'nvenc',
|
||||||
Qsv: 'qsv',
|
Qsv: 'qsv',
|
||||||
Vaapi: 'vaapi',
|
Vaapi: 'vaapi',
|
||||||
|
Rkmpp: 'rkmpp',
|
||||||
Disabled: 'disabled'
|
Disabled: 'disabled'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
|
@ -281,6 +281,10 @@
|
||||||
value: TranscodeHWAccel.Vaapi,
|
value: TranscodeHWAccel.Vaapi,
|
||||||
text: 'VAAPI',
|
text: 'VAAPI',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: TranscodeHWAccel.Rkmpp,
|
||||||
|
text: 'RKMPP (only on Rockchip SOCs)',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: TranscodeHWAccel.Disabled,
|
value: TranscodeHWAccel.Disabled,
|
||||||
text: 'Disabled',
|
text: 'Disabled',
|
||||||
|
|
Loading…
Reference in a new issue