跬步 On Coding

老树发新芽:TN3399v3折腾Jellyfin硬件转码全记录

2021年4月,我女儿刚出生那会儿,我在闲鱼花了300块钱收了一块 TN3399v3 开发板。据卖家说是从报废的商用广告机上拆下来的。当时看中它主要是图便宜,而且板载资源很扎实。一晃5年多过去了,家里很多数码设备换了一茬又一茬,但这块板子一直默默在角落里当我的家庭主力 NAS 和媒体服务器。

要说这块板子这几年用下来有什么遗憾,那就是 Jellyfin 的硬件转码一直没搞定。只要遇到客户端不兼容触发转码,6个核心瞬间拉满到 100%,画面卡成幻灯片。查遍了全网资料,基本结论都是“RK3399 驱动老旧,MPP 不支持编解一体,放弃吧”。

前几天有点闲暇时间,看着这几年大语言模型越来越强,我想着不如死马当活马医,让 AI 帮我一起啃啃这块硬骨头。没想到经过几天的折腾、打补丁、写 Wrapper,竟然真的把这条链路给彻底打通了。实测4路 1080p 视频并发硬转码,CPU 负载不到 30%。

在如今电子硬件普遍大涨价的时代,感觉这块老开发板还能再战好多年。这里把完整的折腾过程、原理分析和落地方案严谨记录下来,供有同款老设备的小伙伴们参考。

1. 硬件规格与折腾背景

TN3399v3 本质上就是一块基于瑞芯微 RK3399 的定制工控板,整体配置跟开源的 Rock Pi 4B 非常接近:

部件名称 芯片型号 备注说明
CPU RK3399 双核 Cortex-A72 (最高 1.8GHz) + 四核 Cortex-A53 (最高 1.4GHz);Mali-T864 GPU
RAM K4B8G16 双通道 DDR3 1GB * 4(共 4GB)
Flash SanDisk SDINBDG4-16G 板载 eMMC 5.1 16GB
PMU RK808D 供电管理单元
Ethernet RTL8211E 千兆以太网口
WIFI+BT AP6255 802.11 a/b/g/n/ac + 蓝牙 4.2
SATA 3.0 JMS578 原生引出 SATA 接口(带 SATA 供电座)
USB FE1.1s + VL817-Q7 2 个 USB 3.0 + 多个 USB 2.0 引出

很多 ARM 架构的小板子做 NAS 最大的痛点就是走 USB 挂移动硬盘,不仅不稳定,速度也受限。而 TN3399v3 最香的地方在于它板载了 JMS578 桥接芯片,自带原生标准的 SATA 接口和供电插针,直接插上一块闲鱼收来的 2.5 寸 2T 机械硬盘,就能严丝合缝组一套超低功耗的私有云。

软件系统基石

在系统选型上,早期折腾时我就发现这块板子的设备树(DTB)和 Rock Pi 4B 基本通用,因此可以直接刷入 Armbian 官方提供的 Rock Pi 4B 固件。

我选择长期停留在比较古老的 Linux 4.4.213-rockchip64 内核版本:

镜像:Armbian_21.08.1_Rockpi-4b_buster_legacy_4.4.213_xfce_desktop.img.xz

https://armbian.tnahosting.net/archive/rockpi-4b/archive/Armbian_21.08.1_Rockpi-4b_buster_legacy_4.4.213_xfce_desktop.img.xz

之所以忍着不升主线高版本内核,核心原因就在于这套 4.4 版本的官方 BSP 内核保留了瑞芯微最完整的底层 VPU 驱动。当时通过安装社区打好补丁的 Kodi:

media-buster-legacy-rk3399_20.11.7_arm64.deb

https://stpete-mirror.armbian.com/users.armbian.com/jmcc/output/Update-2021-04-09/media-buster-legacy-rk3399_20.11.7_arm64.deb

在家里老电视还没换智能电视的那些年,我直接用板载 HDMI 接电视,挂载阿里云盘 WebDAV 看了无数部高清电影,全程 VPU 硬解,CPU 负载微乎其微。

随着家里换了智能电视,Kodi 就功成身退了。这块板子被我彻底改造成了 Docker 服务器:

root@rockpi-4b:~# docker images
REPOSITORY                 TAG       IMAGE ID       CREATED        SIZE
photoprism/photoprism      arm64     391e4507fe13   7 days ago     2.35GB
portainer/portainer-ce     latest    7a040d9261b0   10 days ago    148MB
linuxserver/sonarr         latest    1e33bf358b9b   10 days ago    222MB
linuxserver/jellyfin       latest    beb261220d30   11 days ago    816MB
linuxserver/resilio-sync   latest    190c4473f1db   12 days ago    176MB
filebrowser/filebrowser    latest    e9116d24fb8f   2 months ago   36.5MB
containrrr/watchtower      latest    c352868a1654   2 years ago    14.2MB
p3terx/aria2-pro           latest    0e54e38386ec   4 years ago    28MB

配合宿主机的 Samba、Tailscale 异地组网、Xray 旁路代理,这套服务体系一直稳定服役至今。但唯独 Jellyfin 的硬转码,始终是我心里拔不掉的一根刺。


2. 问题定位与思路转变

早年社区对于 RK3399 跑 Jellyfin 硬解的普遍结论是:即便能勉强通过 MPP 调用 VPU 硬解,在视频需要做尺寸缩小(Downscale)或者格式编码时,由于 FFmpeg 缺少对瑞芯微硬件缩放器(RGA)与硬件编码器(VEPU)的高效整合,整个流必须通过 hwdownload 回传给 CPU 做软缩放和软编码,瞬间把 6 个核心吃死。

中途我甚至看到有人在飞牛 OS 下折腾出了部分硬件支持,但飞牛 OS 对老旧开发板的设备树驱动并不完整,刷机大概率遇到网卡、SATA 无法驱动的坑,风险太大。

这次趁着空闲,我让 AI 帮我重新审视系统底层。AI 扫描内核配置后给出了一个定性结论:当前运行的 4.4.213 内核不仅有完整的 VPU 解码(rkvdec/vpu_service),也有 VEPU 硬件编码支持,甚至硬件 RGA(2D 图形加速引擎)驱动也是完好的。

zcat /proc/config.gz | grep -E 'CONFIG_(VIDEO_ROCKCHIP_VPU|RK_VCODEC|ROCKCHIP_MPP_SERVICE|ROCKCHIP_MPP_DEVICE|ROCKCHIP_RGA|ION)='

输出显示:

CONFIG_VIDEO_ROCKCHIP_VPU=y
CONFIG_RK_VCODEC=y
CONFIG_ROCKCHIP_MPP_SERVICE=y
CONFIG_ROCKCHIP_MPP_DEVICE=y
CONFIG_ION=y

底层能力一直都在,缺的只是用户态现代多媒体工具链的适配。

最近几年,以 @nyanmisaka 大佬为代表的社区力量,在比较新的 RK3568、RK3588 平台上构建了一套非常成熟的软硬件转码闭环(包括 MPP、librga、ffmpeg-rockchip)。只是因为 RK3399 时代太久远,没人愿意专门给老设备做适配维护而已。

既然 RK3568/RK3588 的整套逻辑是基于 MPP 和 RGA 构建的,那么这套路径完全可以反向移植回 RK3399。


3. 核心踩坑与架构妥协

在实际把新工具链拉回老内核时,我碰到了两个最核心的暗坑:

坑一:老内核的 RGA2 驱动不支持新版异步 ioctl

新版 librga 和 ffmpeg-rockchip 针对较新的内核设计,默认会通过字符串查询硬件特性版本(querystring(RGA_VERSION)),并且在图像帧缩放 blit 处理时强制依赖异步栅栏(Fence)等待机制。

但 4.4.213 内核下的 RGA 驱动非常老,本质上是 legacy 驱动。它根本不响应新版本的 ioctl 特性查询(会在 dmesg 里吐出 rga2: unknown ioctl cmd!),更不支持异步回调通知。如果不改源码,FFmpeg 会因为拿不到版本信息报错,或者进入死等崩溃状态。

解法:给 ffmpeg-rockchip 提交针对 legacy RGA2 的同步模式补丁,宏定义打开后跳过版本探测,直接硬编码指定设备为原生 RGA2,且强制使用 RGA_BLIT_SYNC 同步操作,不请求异步 Fence,处理完毕立刻把帧交出。

坑二:overlay_rkrga 导致的绿屏问题

硬解(RKMPP)和硬缩放(RGA)都搞定后,遇到了一个更恶心的问题:一旦视频带有外挂/内嵌字幕,触发 Jellyfin 的字幕烧录(Subtitle Burn-in)逻辑时,如果试图走 RGA 硬件贴图滤镜(overlay_rkrga),把 CPU 渲染出的 BGRA 字幕贴在硬件 NV12 画面上,最终画面会直接变成大面积偏绿。

排查发现,这是老版 RGA2 硬件驱动在处理带透明通道的 BGRA 混合贴图时的老 Bug。无论在用户态尝试 premultiplied alpha 还是 straight alpha,都无法避免。

解法(工程上的妥协与平衡):

做架构设计必须懂得妥协。Jellyfin 大多数现代客户端(如 Android TV、Web、手机端)都支持字幕直接外传,根本无需服务端烧录。退一步讲,即便真的需要服务端烧录字幕,CPU 纯跑 libass 渲染文本的开销非常小,真正卡脖子的是视频解码、画面分辨率缩放和视频重编码。

因此我制定了一条混合转码流水线:

4K HEVC 10bit 输入 (DRM_PRIME)
    ↓ 【RKMPP 硬件解码】
RGA 显存直接缩放至 1080p (vpp_rkrga)
    ↓ 【hwdownload 提取单帧到内存】
libass CPU 烧录字幕(无字幕则跳过此步)
    ↓ 【RKMPP 硬件编码】
H.264 1080p 输出 (h264_rkmpp)

最终这套折中方案大获全胜。


4. 完整编译与环境构建

以下是在 Debian 10 (buster) 宿主机环境下的完整操作流水账。所有操作均需 root 权限。

4.1 编译与安装 MPP

瑞芯微的 Media Process Platform 是与内核通信的基础:

apt-get update
apt-get install -y git cmake pkg-config build-essential

cd /tmp
git clone --depth 1 https://github.com/rockchip-linux/mpp.git mpp
cd mpp
mkdir -p build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
make -j6
make install
ldconfig

验证编码解码器动态库是否就绪:

export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
mpi_enc_test -w 640 -h 360 -f 0 -t 7 -i input.nv12 -o out.h264 -n 5
mpi_dec_test -t 7 -w 640 -h 360 -i out.h264 -o out.yuv

4.2 编译与安装 librga

针对 RK 平台的 2D 缩放引擎,拉取带有 Jellyfin 适配的源码分支:

apt-get install -y meson ninja-build pkg-config

cd /tmp
git clone --depth 1 -b jellyfin-rga \
  https://github.com/nyanmisaka/rk-mirrors.git rkrga

meson setup /tmp/rkrga_build \
  --prefix=/usr/local \
  --libdir=lib \
  --buildtype=release \
  --default-library=shared \
  -Dlibdrm=false \
  -Dlibrga_demo=false

ninja -C /tmp/rkrga_build
ninja -C /tmp/rkrga_build install
ldconfig

安装完成后,确保能够读出版本:

export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:$PKG_CONFIG_PATH
pkg-config --modversion librga

4.3 编译带 legacy RGA2 补丁的 FFmpeg

先安装必要的依赖项:

apt-get install -y \
  git pkg-config yasm nasm patchelf \
  libdrm-dev zlib1g-dev \
  libx264-dev libx265-dev libvpx-dev \
  libass-dev

拉取 6.0 分支源码:

cd /tmp
git clone --depth 1 --branch 6.0 \
  https://github.com/nyanmisaka/ffmpeg-rockchip.git ffmpeg-rockchip
cd ffmpeg-rockchip

应用 legacy RGA2 补丁:

将以下补丁内容保存为 legacy_rga2.patch 并执行 git apply legacy_rga2.patch:

diff --git a/libavfilter/rkrga_common.c b/libavfilter/rkrga_common.c
index cf51025..89dea75 100644
--- a/libavfilter/rkrga_common.c
+++ b/libavfilter/rkrga_common.c
@@ -1071,15 +1071,25 @@ av_cold int ff_rkrga_init(AVFilterContext *avctx, RKRGAParam *param)
     RKRGAContext *r = avctx->priv;
     int i, ret;
     int rga_core_mask = 0x7;
+#if FF_RKRGA_LEGACY_RGA2
+    /* Legacy RGA2: the driver does not support the RGA_VERSION querystring,
+     * so the hw version is assumed to be a plain RGA2. */
+    r->has_rga2  = 1;
+    r->has_rga2l = 0;
+    r->has_rga2e = 0;
+    r->has_rga2p = 0;
+    r->has_rga3  = 0;
+#else
     const char *rga_ver = querystring(RGA_VERSION);

-    r->got_frame = 0;
-
     r->has_rga2  = !!strstr(rga_ver, "RGA_2");
     r->has_rga2l = !!strstr(rga_ver, "RGA_2_lite");
     r->has_rga2e = !!strstr(rga_ver, "RGA_2_Enhance");
     r->has_rga2p = !!strstr(rga_ver, "RGA_2_PRO");
     r->has_rga3  = !!strstr(rga_ver, "RGA_3");
+#endif
+
+    r->got_frame = 0;

     if (!(r->has_rga2 || r->has_rga3)) {
         av_log(avctx, AV_LOG_ERROR, "No RGA2/RGA3 hw available\n");
@@ -1265,6 +1275,13 @@ static int call_rkrga_blit(AVFilterContext *avctx,
     PRINT_RGA_INFO(avctx, pat_info, "pat");
 #undef PRINT_RGA_INFO

+#if FF_RKRGA_LEGACY_RGA2
+    /* Legacy RGA2 has no async support, and no output fence is delivered
+     * to userspace, so always use the synchronous blit interface. */
+    dst_info->sync_mode = RGA_BLIT_SYNC;
+    dst_info->out_fence_fd = -1;
+#endif
+
     if ((ret = c_RkRgaBlit(src_info, dst_info, pat_info)) != 0) {
         av_log(avctx, AV_LOG_ERROR, "RGA blit failed: %d\n", ret);
         return AVERROR_EXTERNAL;
@@ -1384,6 +1401,20 @@ int ff_rkrga_filter_frame(RKRGAContext *r,
     if (ret < 0)
         return ret;

+#if FF_RKRGA_LEGACY_RGA2
+    /* The blit above is already synchronous, so output the frame right away
+     * instead of queueing it for an (unavailable) async fence wait. */
+    filter_ret = r->filter_frame(outlink, dst_frame->frame);
+    if (filter_ret < 0) {
+        av_frame_free(&dst_frame->frame);
+        return filter_ret;
+    }
+    dst_frame->queued--;
+    r->got_frame = 1;
+    dst_frame->frame = NULL;
+
+    return 0;
+#else
     dst_frame->queued++;
     aframe = (RGAAsyncFrame){ src_frame, dst_frame, pat_frame };
     set_rga_async_frame_lock_status(&aframe, 1);
@@ -1409,4 +1440,5 @@ int ff_rkrga_filter_frame(RKRGAContext *r,
     }

     return 0;
+#endif
 }
diff --git a/libavfilter/rkrga_common.h b/libavfilter/rkrga_common.h
index 9e88477..f548dc5 100644
--- a/libavfilter/rkrga_common.h
+++ b/libavfilter/rkrga_common.h
@@ -34,6 +34,20 @@
 #include "libavutil/hwcontext.h"
 #include "libavutil/hwcontext_rkmpp.h"

+/*
+ * Force the RGA filters to run in "legacy RGA2" mode.
+ *
+ * This is required for old vendor kernels/drivers (e.g. Linux 4.4 + rga
+ * v1 driver) where c_RkRgaBlit() has to go through the legacy
+ * RGA_BLIT_SYNC interface instead of the im2d async one, and where
+ * RGA_VERSION querystring() is not available.
+ *
+ * Build with: --extra-cflags="-DFF_RKRGA_LEGACY_RGA2=1"
+ */
+#ifndef FF_RKRGA_LEGACY_RGA2
+#define FF_RKRGA_LEGACY_RGA2 0
+#endif
+
 #define RK_RGA_YUV_ALIGN                2
 #define RK_RGA_AFBC_16x16_STRIDE_ALIGN  16
 #define RK_RGA_RFBC_64x4_STRIDE_ALIGN_W 64
diff --git a/libavfilter/vf_vpp_rkrga.c b/libavfilter/vf_vpp_rkrga.c
index b382263..a8816a4 100644
--- a/libavfilter/vf_vpp_rkrga.c
+++ b/libavfilter/vf_vpp_rkrga.c
@@ -305,8 +305,14 @@ static av_cold void config_force_format(AVFilterContext *ctx,
         return;

     /* Auto fallback to 8-bit fmts on RGA2 */
+#if FF_RKRGA_LEGACY_RGA2
+    /* Legacy RGA2: skip the RGA_VERSION querystring, RGA2 has no 10-bit output */
+    has_rga3 = 0;
+    (void)rga_ver;
+#else
     rga_ver = querystring(RGA_VERSION);
     has_rga3 = !!strstr(rga_ver, "RGA_3");
+#endif
     if (out_depth >= 10 && !has_rga3)
         out_depth = 8;

配置编译参数(务必带上 -DFF_RKRGA_LEGACY_RGA2=1,且禁用 OpenCL):

export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:$PKG_CONFIG_PATH

./configure \
  --prefix=/usr/local \
  --enable-gpl \
  --enable-version3 \
  --enable-libdrm \
  --enable-rkmpp \
  --enable-rkrga \
  --enable-libx264 \
  --enable-libx265 \
  --enable-libvpx \
  --enable-libass \
  --enable-shared \
  --disable-static \
  --disable-doc \
  --disable-debug \
  --extra-cflags="-DFF_RKRGA_LEGACY_RGA2=1"

make -j6
make install
ldconfig

编译完成后确认滤镜是否正常打入:

ffmpeg -hide_banner -filters | grep -E 'subtitles|overlay_rkrga|vpp_rkrga'

能看到 subtitles、vpp_rkrga 即说明编译成功。

4.4 欺骗 Jellyfin:修补 FFmpeg 版本字符串

Jellyfin 在启动调用 FFmpeg 前,会先执行 ffmpeg -version 嗅探程序版本。如果识别到不符合规范的 Git Commit 哈希串(如 ad9403c),Jellyfin 内部的 EncodingHelper 会武断地认为这是一个低版本的老工具,从而强制降级回落后的参数集,甚至丢弃某些硬件参数。

用一段简单的 Python 脚本对编译出的二进制进行版本硬修补:

# patch_ver.py
import sys

OLD = b"ad9403c"
NEW = b"6.0.1\x00\x00"
assert len(OLD) == len(NEW) == 7

for path in sys.argv[1:]:
    data = open(path, "rb").read()
    n = data.count(OLD)
    if not n:
        print("NOOP", path)
        continue
    pos = -1
    for _ in range(n):
        pos = data.find(OLD, pos + 1)
        before = data[max(0, pos - 40):pos]
        assert b"version " in before or before.endswith(b"\x00")
    open(path, "wb").write(data.replace(OLD, NEW))
    print("PATCHED", n, path)

执行修补:

python3 patch_ver.py /usr/local/bin/ffmpeg /usr/local/bin/ffprobe

4.5 构建自足 Bundle(解耦容器与宿主机)

我们的 Jellyfin 运行在 Docker 容器中(基于 Ubuntu 环境),如果直接把宿主机编译的二进制挂进去,容易碰到系统依赖库版本冲突。我们把编译出的二进制与相关 .so 依赖抽离打包成一个完整的 Bundle,并利用 patchelf 将动态链接指向内部相对路径:

# 替换为你的数据盘实际挂载路径
B=/srv/dev-disk-by-uuid-<DISK-UUID>/volumes/jellyfin-rkmpp-ffmpeg
rm -rf "$B"
mkdir -p "$B/lib"

install -m 0755 /usr/local/bin/ffmpeg  "$B/ffmpeg.real"
install -m 0755 /usr/local/bin/ffprobe "$B/ffprobe"

# 复制 8 个 FFmpeg 核心库
for n in libavcodec.so.60 libavdevice.so.60 libavfilter.so.9 libavformat.so.60 \
         libavutil.so.58 libpostproc.so.57 libswresample.so.4 libswscale.so.7; do
  cp -L "/usr/local/lib/$n" "$B/lib/$n"
done

# 复制 MPP/RGA 及 CPU 编解码器
for n in librockchip_mpp.so.1 librga.so.2; do
  cp -L "/usr/local/lib/$n" "$B/lib/$n"
done

for n in libx264.so.155 libx265.so.165 libvpx.so.5; do
  cp -L "/lib/aarch64-linux-gnu/$n" "$B/lib/$n"
done

# 复制 libass 及其字体渲染链路依赖
for n in libass.so.9 libfribidi.so.0 libfontconfig.so.1 libfreetype.so.6 \
         libharfbuzz.so.0 libexpat.so.1 libuuid.so.1 libpng16.so.16 \
         libglib-2.0.so.0 libgraphite2.so.3 libpcre.so.3 libz.so.1; do
  cp -L "/lib/aarch64-linux-gnu/$n" "$B/lib/$n"
done

# 修正 RPATH,确保优先从内部 lib 加载
patchelf --set-rpath '$ORIGIN/lib' "$B/ffmpeg.real"
patchelf --set-rpath '$ORIGIN/lib' "$B/ffprobe"

for f in "$B"/lib/*.so*; do
  patchelf --force-rpath --set-rpath '$ORIGIN' "$f" 2>/dev/null || true
done

检查依赖隔离性,确保没有找不到的链接:

docker run --rm --entrypoint /bin/sh \
  -v "$B:/opt/rkmpp-ffmpeg:ro" \
  linuxserver/jellyfin:latest \
  -c 'ldd /opt/rkmpp-ffmpeg/ffmpeg.real 2>&1 | grep "not found"'

输出必须为空。


5. Jellyfin 容器部署与配置

5.1 Docker Compose 配置

创建或修改你的 docker-compose.yml,重点在于映射底层的视频与图形设备节点,开放组权限,并且由于 MPP 需要读取 /proc/device-tree,必须带上 security_opt: [systempaths=unconfined]:

version: "2.0"
services:
  jellyfin:
    image: linuxserver/jellyfin:latest
    container_name: jellyfin
    mem_limit: 1.5g
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Asia/Shanghai
      - FFMPEG_PATH=/opt/rkmpp-ffmpeg/ffmpeg
    ports:
      - "8096:8096"
    devices:
      - /dev/vpu_service
      - /dev/rkvdec
      - /dev/rga
      - /dev/dri
    group_add:
      - "44"   # video 用户组 GID
      - "108"  # render 用户组 GID
    security_opt:
      - systempaths=unconfined
    volumes:
      - /srv/dev-disk-by-uuid-<DISK-UUID>/volumes/jellyfin:/config
      - /srv/dev-disk-by-uuid-<DISK-UUID>/TvShows:/data/tvshows
      - /srv/dev-disk-by-uuid-<DISK-UUID>/Movies:/data/movies
      - /srv/dev-disk-by-uuid-<DISK-UUID>/volumes/jellyfin-rkmpp-ffmpeg:/opt/rkmpp-ffmpeg:ro
    network_mode: bridge
    restart: unless-stopped

5.2 核心拦截转换脚本:Wrapper

Jellyfin 自身生成的滤镜链往往并不理解 RK3399 上的这套特殊限制,因此我们需要写一个智能的 Shell Wrapper 作为入口,在实际调用真实 FFmpeg 之前对命令行参数做动态重写。

将以下脚本保存为 $B/ffmpeg 并赋予执行权限 chmod +x $B/ffmpeg:

#!/bin/bash
# Jellyfin FFmpeg wrapper for RK3399/TN3399 v3.
#
# Modes:
#   rga             simple RKMPP -> scale -> H.264 RKMPP
#   rga-subtitles   text subtitle burn-in: RGA scale + safe CPU subtitle burn
#   software        conservative fallback with fast_bilinear
#
# Text subtitle mode is enabled only when the incoming Jellyfin graph
# contains an actual subtitles= filter. A separately delivered subtitle
# track does not contain that filter and therefore never enters this mode.
# The main video is scaled by RGA; subtitles are rendered by libass after a
# single hwdownload because legacy rga2 BGRA overlay can produce green frames.
#
# Debug:
#   JELLYFIN_RGA_WRAPPER=0          disable RGA rewrite
#   JELLYFIN_RGA_WRAPPER_DRYRUN=1  print argv and do not execute FFmpeg
#   JELLYFIN_RGA_WRAPPER_LOG=...   append rewritten argv to a log
#   JELLYFIN_RGA_REAL=...          override ffmpeg.real (testing)
#   JELLYFIN_RGA_PROBE=...         override ffprobe (testing)

REAL=${JELLYFIN_RGA_REAL:-/opt/rkmpp-ffmpeg/ffmpeg.real}
PROBE=${JELLYFIN_RGA_PROBE:-/opt/rkmpp-ffmpeg/ffprobe}
LOG=${JELLYFIN_RGA_WRAPPER_LOG:-/tmp/jellyfin-rga-wrapper.log}

log_args() {
    [ -n "$LOG" ] || return 0
    {
        printf '%s mode=%s' "$(date '+%F %T')" "$1"
        shift
        printf ' %q' "$@"
        printf '\n'
    } >> "$LOG" 2>/dev/null || true
}

has_exact_arg() {
    local needle=$1 a
    shift
    for a in "$@"; do
        [ "$a" = "$needle" ] && return 0
    done
    return 1
}

trim() {
    local s=$1
    s=${s#"${s%%[![:space:]]*}"}
    s=${s%"${s##*[![:space:]]}"}
    printf '%s' "$s"
}

# Split on commas not escaped as "\,".
split_filter_graph() {
    local graph=$1 cur='' i ch
    FILTER_PARTS=()
    i=0
    while [ "$i" -lt "${#graph}" ]; do
        ch=${graph:i:1}
        if [ "$ch" = '\' ] && [ "$((i + 1))" -lt "${#graph}" ]; then
            cur+=${graph:i:2}
            i=$((i + 2))
        elif [ "$ch" = ',' ]; then
            FILTER_PARTS+=("$cur")
            cur=''
            i=$((i + 1))
        else
            cur+=$ch
            i=$((i + 1))
        fi
    done
    FILTER_PARTS+=("$cur")
}

parse_scale_geometry() {
    local scale=$1 geo width height
    case "$scale" in
        scale=*) geo=${scale#scale=} ;;
        *) return 1 ;;
    esac
    case "$geo" in
        *:flags=*|*flags=*) return 1 ;;
    esac
    case "$geo" in
        *:*)
            width=${geo%%:*}
            height=${geo#*:}
            ;;
        *) return 1 ;;
    esac
    case "$height" in
        *:*) return 1 ;;
    esac
    [ -n "$width" ] && [ -n "$height" ] || return 1
    SCALE_WIDTH=$width
    SCALE_HEIGHT=$height
    SCALE_GEO=$geo
    return 0
}

probe_input_size() {
    local input=$1 result iw ih
    [ -n "$input" ] || return 1
    [ -x "$PROBE" ] || [ -f "$PROBE" ] || return 1
    result=$("$PROBE" -v error -select_streams v:0 \
        -show_entries stream=width,height -of csv=p=0 "$input" 2>/dev/null) || return 1
    result=${result//$'\r'/}
    result=${result//$'\n'/}
    [ -n "$result" ] || return 1
    IFS=, read -r iw ih <<< "$result"
    case "$iw" in ''|*[!0-9]*) return 1 ;; esac
    case "$ih" in ''|*[!0-9]*) return 1 ;; esac
    INPUT_WIDTH=$iw
    INPUT_HEIGHT=$ih
    return 0
}

# Build a hardware subtitle graph only for Jellyfin's text-subtitle shape:
# setparams, scale, format, subtitles=...
try_text_subtitle_graph() {
    local graph=$1 part main_pre='' scale_part='' subtitle_part=''
    local scale_index=-1 subtitle_index=-1 idx
    local -a parts=()

    split_filter_graph "$graph"
    parts=("${FILTER_PARTS[@]}")

    for idx in "${!parts[@]}"; do
        part=$(trim "${parts[idx]}")
        case "$part" in
            setparams=*)
                [ -z "$main_pre" ] || return 1
                main_pre=$part
                ;;
            scale=*)
                [ "$scale_index" -lt 0 ] || return 1
                scale_index=$idx
                scale_part=$part
                ;;
            format=nv12|format=yuv420p)
                # The RGA filter emits the output format itself.
                ;;
            subtitles=*)
                [ "$subtitle_index" -lt 0 ] || return 1
                subtitle_index=$idx
                subtitle_part=$part
                ;;
            '')
                ;;
            *)
                return 1
                ;;
        esac
    done

    [ "$scale_index" -ge 0 ] || return 1
    [ "$subtitle_index" -gt "$scale_index" ] || return 1
    parse_scale_geometry "$scale_part" || return 1

    local main_scale="vpp_rkrga=w=$SCALE_WIDTH:h=$SCALE_HEIGHT:format=nv12"
    local main_chain=''
    [ -n "$main_pre" ] && main_chain="$main_pre,"
    main_chain+="$main_scale"

    # RGA scales the main video in DRM_PRIME. The scaled frame is then
    # downloaded once and subtitles are rendered by libass on CPU. This avoids
    # the green-frame bug observed when legacy rga2 overlays a BGRA surface.
    RGA_GRAPH="[0:v]${main_chain},hwdownload,format=nv12,${subtitle_part}[out]"
    RGA_SUBTITLE=1
    return 0
}

# Simple no-subtitle graph: setparams, scale, format.
try_simple_graph() {
    local graph=$1 part scale_count=0 format_count=0 format_after_scale=0
    local scale_geo width height
    local -a parts=()

    split_filter_graph "$graph"
    parts=("${FILTER_PARTS[@]}")
    SIMPLE_GRAPH=''

    for part in "${parts[@]}"; do
        part=$(trim "$part")
        case "$part" in
            scale=*)
                [ "$scale_count" -eq 0 ] || return 1
                parse_scale_geometry "$part" || return 1
                scale_geo=$SCALE_GEO
                width=$SCALE_WIDTH
                height=$SCALE_HEIGHT
                [ -n "$SIMPLE_GRAPH" ] && SIMPLE_GRAPH+=','
                SIMPLE_GRAPH+="vpp_rkrga=w=$width:h=$height:format=nv12"
                scale_count=$((scale_count + 1))
                format_after_scale=0
                ;;
            format=nv12|format=yuv420p)
                format_count=$((format_count + 1))
                if [ "$format_after_scale" -eq 1 ]; then
                    continue
                fi
                [ -n "$SIMPLE_GRAPH" ] && SIMPLE_GRAPH+=','
                SIMPLE_GRAPH+=$part
                ;;
            setparams=*)
                [ -z "$SIMPLE_GRAPH" ] || return 1
                SIMPLE_GRAPH=$part
                ;;
            '')
                ;;
            *)
                return 1
                ;;
        esac
        if [ "$scale_count" -eq 1 ] && [[ "$SIMPLE_GRAPH" == *vpp_rkrga* ]]; then
            format_after_scale=1
        elif [ "$format_after_scale" -eq 1 ] && [[ "$part" != format=nv12 && "$part" != format=yuv420p ]]; then
            format_after_scale=0
        fi
    done

    [ "$scale_count" -eq 1 ] && [ "$format_count" -eq 1 ] || return 1
    return 0
}

find_vf_graph() {
    local -a src=("$@")
    local i
    VF_INDEX=-1
    VF_GRAPH=''
    INPUT_PATH=''
    for ((i = 0; i < ${#src[@]}; i++)); do
        if [ "${src[i]}" = '-vf' ] || [ "${src[i]}" = '-filter:v' ]; then
            [ "$VF_INDEX" -lt 0 ] || return 1
            VF_INDEX=$i
            VF_GRAPH=${src[i+1]:-}
        fi
        if [ "${src[i]}" = '-i' ]; then
            [ -z "$INPUT_PATH" ] && INPUT_PATH=${src[i+1]:-}
        fi
    done
    [ "$VF_INDEX" -ge 0 ] && [ -n "$VF_GRAPH" ]
}

find_video_map() {
    local -a src=("$@")
    local i value
    VIDEO_MAP_INDEX=-1
    for ((i = 0; i < ${#src[@]}; i++)); do
        if [ "${src[i]}" = '-map' ]; then
            value=${src[i+1]:-}
            case "$value" in
                '0:0'|'0:v:0')
                    [ "$VIDEO_MAP_INDEX" -lt 0 ] || return 1
                    VIDEO_MAP_INDEX=$i
                    ;;
            esac
        fi
    done
    [ "$VIDEO_MAP_INDEX" -ge 0 ]
}

# Rebuild argv for either simple or subtitle hardware graph.
build_rga_args() {
    local -a src=("$@") out=()
    local i have_hw_rk=0 inserted_hw=0

    for ((i = 0; i < ${#src[@]}; i++)); do
        if [ "${src[i]}" = '-hwaccel' ] && [ "${src[i+1]:-}" = 'rkmpp' ]; then
            have_hw_rk=1
            break
        fi
    done

    for ((i = 0; i < ${#src[@]}; i++)); do
        if [ "${src[i]}" = '-i' ] && [ "$have_hw_rk" -eq 0 ] && [ "$inserted_hw" -eq 0 ]; then
            out+=('-hwaccel' 'rkmpp' '-hwaccel_output_format' 'drm_prime')
            inserted_hw=1
        fi
        if [ "${src[i]}" = '-hwaccel' ] && [ "${src[i+1]:-}" = 'rkmpp' ]; then
            out+=('-hwaccel' 'rkmpp' '-hwaccel_output_format' 'drm_prime')
            i=$((i + 1))
            continue
        fi
        if [ "$i" -eq "$VF_INDEX" ]; then
            if [ "${RGA_SUBTITLE:-0}" -eq 1 ]; then
                out+=('-filter_complex' "$RGA_GRAPH")
            else
                out+=('-vf' "$SIMPLE_GRAPH")
            fi
            i=$((i + 1))
            continue
        fi
        if [ "$i" -eq "$VIDEO_MAP_INDEX" ] && [ "${RGA_SUBTITLE:-0}" -eq 1 ]; then
            out+=('-map' '[out]')
            i=$((i + 1))
            continue
        fi
        out+=("${src[i]}")
    done
    RGA_ARGS=("${out[@]}")
}

build_software_args() {
    local -a src=("$@") out=()
    local i inserted=0
    for i in "${!src[@]}"; do
        if [ "$inserted" -eq 0 ]; then
            case "${src[i]}" in
                -vf|-filter|-filter_complex|-filter:v*)
                    out+=('-sws_flags' 'fast_bilinear')
                    inserted=1
                    ;;
            esac
        fi
        out+=("${src[i]}")
    done
    SW_ARGS=("${out[@]}")
}

args=("$@")
use_rga=0
RGA_SUBTITLE=0
VIDEO_MAP_INDEX=-1
if [ "${JELLYFIN_RGA_WRAPPER:-1}" != '0' ] \
    && has_exact_arg 'h264_rkmpp' "$@" \
    && ! has_exact_arg '-filter_complex' "$@" \
    && ! has_exact_arg '-hwaccel_output_format' "$@"; then
    input_count=0
    init_rk=0
    hw_rk=0
    other_hw=0
    force_decode=0
    for ((i = 0; i < ${#args[@]}; i++)); do
        [ "${args[i]}" = '-i' ] && input_count=$((input_count + 1))
        if [ "${args[i]}" = '-init_hw_device' ] && [[ "${args[i+1]:-}" == rkmpp=* ]]; then
            init_rk=1
        fi
        if [ "${args[i]}" = '-hwaccel' ]; then
            if [ "${args[i+1]:-}" = 'rkmpp' ]; then
                hw_rk=1
            else
                other_hw=1
            fi
        fi
    done
    if [ "$input_count" -eq 1 ] && [ "$init_rk" -eq 1 ] && [ "$other_hw" -eq 0 ]; then
        if find_vf_graph "$@" && [[ "$VF_GRAPH" == *subtitles=* ]]; then
            if find_video_map "$@"; then
                if try_text_subtitle_graph "$VF_GRAPH"; then
                    build_rga_args "$@" && use_rga=1
                fi
            fi
        elif [ "$hw_rk" -eq 1 ] || { [ "$hw_rk" -eq 0 ] && [[ "${args[*]:-}" == *format=yuv420p* ]]; }; then
            if find_vf_graph "$@" && try_simple_graph "$VF_GRAPH"; then
                build_rga_args "$@" && use_rga=1
            fi
        fi
    fi
fi

if [ "$use_rga" -eq 1 ]; then
    if [ "$RGA_SUBTITLE" -eq 1 ]; then
        log_args rga-subtitles "${RGA_ARGS[@]}"
        mode=rga-subtitles
    else
        log_args rga "${RGA_ARGS[@]}"
        mode=rga
    fi
    if [ "${JELLYFIN_RGA_WRAPPER_DRYRUN:-0}" = '1' ]; then
        printf '%s' "$mode"; printf ' %q' "${RGA_ARGS[@]}"; printf '\n'
        exit 0
    fi
    exec "$REAL" "${RGA_ARGS[@]}"
fi

build_software_args "$@"
log_args software "${SW_ARGS[@]}"
if [ "${JELLYFIN_RGA_WRAPPER_DRYRUN:-0}" = '1' ]; then
    printf 'SOFTWARE'; printf ' %q' "${SW_ARGS[@]}"; printf '\n'
    exit 0
fi
exec "$REAL" "${SW_ARGS[@]}"

5.3 Jellyfin 控制台设置

在容器启动后,登录 Jellyfin 控制台进行配置:

  1. 进入 控制台 → 播放 → 硬件加速:

    • 硬件加速选项选择:RKMPP
    • 启用硬件编码:勾选
    • 硬件解码格式:将 H264、HEVC、VC1、VP8、VP9、MPEG2 全部勾选。
    • 特别注意:务必勾选 启用 10 位 HEVC 解码(现在海量 4K 资源都是 HEVC Main 10,RK3399 的 rkvdec 硬解 10bit 没有任何压力;如果不勾选,遇到 10bit 会强制回落到 CPU 软解,直接导致转码暴毙)。
    • 色调映射(Tone mapping):不要开启(Mali-T864 的 OpenCL 支持在该内核下跑不通互操作)。
    • 编码格式:仅保留 H.264(RK3399 没有硬件 HEVC 编码器,编码只能走 H.264)。
  2. 客户端字幕烧录选项避坑: 千万不要试图直接在服务端的 encoding.xml 配置文件中强加 <AlwaysBurnInSubtitleWhenTranscoding>,这是 Web 客户端的 Session 级状态参数,写进 xml 会被 Jellyfin 服务端初始化时自动覆盖或抹除。
    如果客户端确实需要服务端强制烧录字幕,应该在具体播放端的客户端中开启(设置 → 字幕 → 烧录字幕 选择 始终)。我们的 wrapper 能够智能嗅探请求中是否真的带有 subtitles= 滤镜,按需切换转码模式。


6. 实测验证与压测

改完这一整套方案后,启动转码播放,通过以下手段观察系统状态:

6.1 检查实际执行参数与日志

进入容器内查看 wrapper 输出:

docker exec jellyfin tail -f /tmp/jellyfin-rga-wrapper.log

观察到日志输出为 mode=rga 或 mode=rga-subtitles,说明入参已成功命中硬件加速链路。

在宿主机上抓取实际执行的进程命令行:

for p in $(pgrep -x ffmpeg.real); do
    tr '\0' ' ' < /proc/$p/cmdline
    echo
done

看到参数里准确包含了 vpp_rkrga=,且带有文本字幕时包含 hwdownload,format=nv12,subtitles=... 且编码器为 h264_rkmpp,说明流水线完全吻合预期。

6.2 硬件中断计数(硬解硬编的照妖镜)

软件层面的监控有时会说谎,但硬件中断不会。通过监控 /proc/interrupts 中的硬件中断变化,能 100% 确认硬件单元是否参与了工作:

  • IRQ 48:rkvdec(RK3399 的主力高清硬件解码器)
  • IRQ 46:vpu_service(VEPU 硬件编码器)
  • IRQ 50:rga(2D 硬件缩放器)

运行监控脚本:

r0=$(awk '$1=="50:"{s+=$2} END{print s+0}' /proc/interrupts)
d0=$(awk '$1=="48:"{s+=$2} END{print s+0}' /proc/interrupts)
e0=$(awk '$1=="46:"{s+=$2} END{print s+0}' /proc/interrupts)
sleep 5
r1=$(awk '$1=="50:"{s+=$2} END{print s+0}' /proc/interrupts)
d1=$(awk '$1=="48:"{s+=$2} END{print s+0}' /proc/interrupts)
e1=$(awk '$1=="46:"{s+=$2} END{print s+0}' /proc/interrupts)
echo "RGA_DIFF=$((r1-r0)) DEC_DIFF=$((d1-d0)) ENC_DIFF=$((e1-e0))"

当播放 4K HEVC 10bit 转码 1080p 时,RGA_DIFF、DEC_DIFF 和 ENC_DIFF 每秒都在快速增加,硬件单元全员满负荷工作!

6.3 CPU 表现对比

找了一部 4K HDR 10bit、码率 45Mbps 的电影进行转码测试:

测试模式 典型 FFmpeg 单进程 CPU 占用 播放状态
未启用本方案(纯软解软编) 500% ~ 550% 严重卡顿,持续缓冲,基本不可看
打通硬解 + RGA 缩放 + 硬编 29% ~ 35% 丝滑秒开,进度条拖拽响应迅速

四路同时转码时,开发板整机的负载均值也稳定在很低的水平。RK3399 的大核心 Cortex-A72 算力原本就强于 RK3568,如今补齐了硬件加速的最后一块拼图,整体表现甚至优于不少入门级的四核 NAS。


总结

架构演进和底层性能排查其实是一个道理:不能脱离实际场景去追求盲目的技术纯粹性。

在折腾的过程中,很多次我都想过要不要干脆换个新板子,或者为了让字幕贴图也走 RGA 硬加速去死磕驱动重构。但冷静下来想一想,搭建这台家庭 NAS 的根本诉求是为了稳定低功耗地服务家庭娱乐,而不是为了跑分。

当发现 RGA2 在老内核上处理透明通道贴图有硬件缺陷时,退一步采用“硬件硬解 + 硬件缩放 + CPU 极低损耗烧录字幕 + 硬件编码”的混合方案,反而把工程复杂度降到了最低,在妥协中找到了真正的最优解。

这几年 AI 工具的爆发,也实实在在改变了我们这些老程序员折腾技术的方式。原本面对浩如烟海的内核驱动源码、FFmpeg 滤镜链路和晦涩的 ioctl 错误,单凭个人在业余时间死磕,很容易由于信息不对称在中途放弃。而现在有了 AI 的代码级检索和逻辑推理协助,把许多原本只属于专业嵌入式大厂的调优能力,平民化地赋能给了每一个普通的开发者。

老旧的 TN3399v3 又重新焕发了新生,这场折腾很有意思,也收获颇丰。