feat(plex): transcode resource discipline + admin knobs (E6 S1c)

The software-fallback re-encode now runs deprioritized and admin-configurable.
The SW transcode ffmpeg child is niced (preexec_fn → os.nice, applied per-child
because the Popen lives in the long-lived API process, which must NOT itself be
niced — unlike the download worker's whole-process _deprioritize) so a CPU-bound
re-encode can't starve request handling or playback; the VAAPI path needs none of
it.

New sysconfig knobs (plex group, rendered on the admin Configuration page, i18n
HU/EN): plex_transcode_hw (auto/on/off, layered onto _want_hw), plex_transcode_
preset, plex_transcode_crf, plex_transcode_max_height (software-only downscale-to
cap; the GPU path encodes at source resolution). plex_max_transcodes stays the
concurrency cap (comment corrected — the fallback is shipped, not "later").

pytest extends the command-builder + _want_hw coverage (preset/crf/max_height
plumbing, the on/off/auto policy). Browser-UAT'd: max_height=240 drops the live
resolution to 330x240; the SW ffmpeg child runs at nice 10 (ppid=api process).
This commit is contained in:
2026-08-30 01:22:30 +02:00
parent 0658f9550b
commit ce96032edb
6 changed files with 193 additions and 32 deletions
+15 -2
View File
@@ -258,9 +258,22 @@ class Settings(BaseSettings):
# that catches what the incremental history feed can't — notably an un-watch done on the Plex # that catches what the incremental history feed can't — notably an un-watch done on the Plex
# side — so it runs far less often (daily) than the incremental pass. # side — so it runs far less often (daily) than the incremental pass.
plex_watch_reconcile_interval_min: int = 1440 plex_watch_reconcile_interval_min: int = 1440
# Max concurrent transcodes for the P3 fallback. Low by default — CPU-only transcode is # Max concurrent transcode sessions (playable="transcode"). Low by default — the software-fallback
# expensive; direct-serve (browser-compatible files) has no such limit. # re-encode is CPU-heavy (the VAAPI hardware path is far cheaper and could sustain more); direct-serve
# and remux (stream-copy) are not capped by this.
plex_max_transcodes: int = 4 plex_max_transcodes: int = 4
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) ---
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
# node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_want_hw`.
plex_transcode_hw: str = "auto"
# Software-fallback libx264 tuning (IGNORED on the VAAPI path). `preset` trades CPU for size; `crf`
# trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output
# height (0 = keep source) so a CPU re-encode of a tall source stays affordable — the GPU path always
# encodes at source resolution.
plex_transcode_preset: str = "veryfast"
plex_transcode_crf: int = 20
plex_transcode_max_height: int = 1080
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls` # Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
# dir under download_root. On localdev the download_root is a slow Windows bind-mount, so point # dir under download_root. On localdev the download_root is a slow Windows bind-mount, so point
# this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's download_root is fast # this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's download_root is fast
+78 -20
View File
@@ -19,6 +19,7 @@ CPU-only prod host; only P3's full transcode is CPU-heavy.
""" """
import logging import logging
import os
import re import re
import shutil import shutil
import subprocess import subprocess
@@ -167,35 +168,49 @@ def _hwaccel_in_opts(hw: bool) -> list[str]:
return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE] return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE]
def _venc_opts(transcode: bool, hw: bool) -> list[str]: def _venc_opts(
"""The video-encoder args that replace `-c:v copy` for a transcode session (empty-of-meaning: transcode: bool,
returns the copy args when not transcoding). A re-encode must MINT its own keyframes at the segment hw: bool,
boundaries — the copy path leaned on the source's keyframes, but a re-encoded stream has none the preset: str = "veryfast",
HLS muxer can cut on unless we force them (`-force_key_frames` every `_SEG_SECONDS`), else segments crf: int = 20,
aren't independent and seeking breaks.""" max_height: int = 0,
) -> list[str]:
"""The video-encoder args that replace `-c:v copy` for a transcode session (returns the copy args
when not transcoding). A re-encode must MINT its own keyframes at the segment boundaries — the copy
path leaned on the source's keyframes, but a re-encoded stream has none the HLS muxer can cut on
unless we force them (`-force_key_frames` every `_SEG_SECONDS`), else segments aren't independent and
seeking breaks. `preset`/`crf`/`max_height` (software only) come from admin sysconfig."""
if not transcode: if not transcode:
return ["-c:v", "copy"] return ["-c:v", "copy"]
force_kf = ["-force_key_frames", f"expr:gte(t,n_forced*{_SEG_SECONDS})"] force_kf = ["-force_key_frames", f"expr:gte(t,n_forced*{_SEG_SECONDS})"]
if hw: if hw:
# scale_vaapi with format=nv12 downconverts a 10-bit (p010) HEVC source to 8-bit nv12 so # scale_vaapi with format=nv12 downconverts a 10-bit (p010) HEVC source to 8-bit nv12 so
# h264_vaapi (8-bit High) can encode it; for an 8-bit source it's a cheap format pass-through. # h264_vaapi (8-bit High) can encode it; for an 8-bit source it's a cheap format pass-through.
# The GPU encode is cheap, so it runs at source resolution (max_height is a software-only knob).
return ["-vf", "scale_vaapi=format=nv12", "-c:v", "h264_vaapi", *force_kf] return ["-vf", "scale_vaapi=format=nv12", "-c:v", "h264_vaapi", *force_kf]
# Software fallback: same browser-safe recipe as downloads/edit.py (libx264 veryfast/crf20). # Software fallback: same browser-safe recipe as downloads/edit.py (libx264). yuv420p forces 8-bit
# yuv420p forces 8-bit High (not High10) so any browser decodes it; -threads reserves a core so the # High (not High10) so any browser decodes it; -threads reserves a core so the CPU-bound encode can't
# CPU-bound encode can't starve request handling / playback on the shared box. # starve request handling / playback on the shared box.
return [ args: list[str] = []
if max_height and max_height > 0:
# Downscale-ONLY to the height cap (min(ih,H)), keeping aspect with an even width (-2), so a tall
# source doesn't make the software re-encode ruinously expensive. The comma in min() is escaped
# (\,) so ffmpeg's filter parser reads one filter, not two. Never upscales a smaller source.
args += ["-vf", f"scale=-2:min(ih\\,{max_height})"]
args += [
"-c:v", "-c:v",
"libx264", "libx264",
"-preset", "-preset",
"veryfast", preset,
"-crf", "-crf",
"20", str(crf),
"-pix_fmt", "-pix_fmt",
"yuv420p", "yuv420p",
"-threads", "-threads",
str(encode_thread_cap()), str(encode_thread_cap()),
*force_kf, *force_kf,
] ]
return args
def _render_node_present() -> bool: def _render_node_present() -> bool:
@@ -205,15 +220,39 @@ def _render_node_present() -> bool:
return Path(_VAAPI_DEVICE).exists() return Path(_VAAPI_DEVICE).exists()
def _want_hw(item: PlexItem, transcode: bool) -> bool: def _want_hw(db: DbSession, item: PlexItem, transcode: bool) -> bool:
"""Whether to use the VAAPI hardware encoder for this transcode session. True iff we're """Whether to use the VAAPI hardware encoder for this transcode session, honouring the admin
transcoding AND the render node is present AND the iGPU can hardware-decode the source (else the `plex_transcode_hw` policy: "off" → always software; "on" → VAAPI wherever the render node exists
full-GPU pipeline can't feed itself → software libx264). (S1c layers an admin on/off/auto override.)""" (trusting the admin, skipping the source-decodability gate); "auto" (default) → VAAPI only when the
if not transcode or not _render_node_present(): render node is present AND the iGPU can hardware-decode the source (else the full-GPU pipeline can't
feed itself → software libx264)."""
if not transcode:
return False return False
mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower()
if mode == "off" or not _render_node_present():
return False
if mode == "on":
return True
return (item.codec_video or "").lower() in _HW_DECODABLE return (item.codec_video or "").lower() in _HW_DECODABLE
# CPU-nice increment for the SOFTWARE transcode child. The Plex ffmpeg is a Popen in the long-lived API
# process (which must NOT itself be niced), so — unlike the download worker's _deprioritize (run once in
# its own main()) — we lower priority per-child via preexec_fn in the fork. The GPU path needs none of
# this. Reuses the download child's nice default so all co-located re-encoders share one handicap.
_SW_TRANSCODE_NICE = settings.download_child_nice
def _sw_child_preexec() -> None:
"""Runs in the forked child (POSIX) before exec: renice this software-transcode ffmpeg down so it
can't starve the co-located API / Postgres / playback. Best-effort — a failed renice must not fail
playback."""
try:
os.nice(_SW_TRANSCODE_NICE)
except OSError:
pass
def _probe_audio_langs(src: Path) -> list[str]: def _probe_audio_langs(src: Path) -> list[str]:
"""One entry per audio stream (its language tag, or "" if untagged). len() = audio-track count. """One entry per audio stream (its language tag, or "" if untagged). len() = audio-track count.
Used to build the multi-rendition var_stream_map. Empty on probe failure → single-audio path.""" Used to build the multi-rendition var_stream_map. Empty on probe failure → single-audio path."""
@@ -249,10 +288,14 @@ def _ffmpeg_cmd(
audio_langs: list[str] | None = None, audio_langs: list[str] | None = None,
transcode: bool = False, transcode: bool = False,
hw: bool = False, hw: bool = False,
preset: str = "veryfast",
crf: int = 20,
max_height: int = 0,
) -> list[str]: ) -> list[str]:
# For a transcode session the video is re-encoded (VAAPI or libx264) instead of stream-copied; the # For a transcode session the video is re-encoded (VAAPI or libx264) instead of stream-copied; the
# hwaccel decode options go before -i, the encoder args replace `-c:v copy` below. Everything else is # hwaccel decode options go before -i, the encoder args replace `-c:v copy` below. Everything else is
# identical to remux. # identical to remux.
venc = _venc_opts(transcode, hw, preset, crf, max_height)
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw)] args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw)]
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the # Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
# keyframe ffmpeg actually seeked to. hls.js still zero-bases playback to that PTS; we read it back # keyframe ffmpeg actually seeked to. hls.js still zero-bases playback to that PTS; we read it back
@@ -274,7 +317,7 @@ def _ffmpeg_cmd(
args += ["-map", "0:v:0"] args += ["-map", "0:v:0"]
for i in range(len(audio_langs)): for i in range(len(audio_langs)):
args += ["-map", f"{ai}:a:{i}?"] args += ["-map", f"{ai}:a:{i}?"]
args += ["-sn", *_venc_opts(transcode, hw), "-c:a", "aac", "-ac", "2", "-b:a", "192k"] args += ["-sn", *venc, "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
var = ["v:0,agroup:aud"] var = ["v:0,agroup:aud"]
for i, lang in enumerate(audio_langs): for i, lang in enumerate(audio_langs):
entry = f"a:{i},agroup:aud,name:a{i}" entry = f"a:{i},agroup:aud,name:a{i}"
@@ -295,7 +338,7 @@ def _ffmpeg_cmd(
else: else:
ao = audio_ord if audio_ord is not None else 0 ao = audio_ord if audio_ord is not None else 0
args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"] args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"]
args += [*_venc_opts(transcode, hw), "-c:a", "aac", "-ac", "2", "-b:a", "192k"] args += [*venc, "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
args += _HLS_TAIL + [ args += _HLS_TAIL + [
"-hls_segment_filename", "-hls_segment_filename",
str(out_dir / "seg_%d.ts"), str(out_dir / "seg_%d.ts"),
@@ -380,9 +423,20 @@ def start_session(
return None return None
key = item.rating_key key = item.rating_key
start_s = max(0.0, float(start_s)) start_s = max(0.0, float(start_s))
hw = _want_hw(item, transcode) hw = _want_hw(db, item, transcode)
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and remux
# ignore preset/crf/max_height, and remux must not touch the DB for them).
preset, crf, max_height = "veryfast", 20, 0
if transcode and not hw:
preset = sysconfig.get_str(db, "plex_transcode_preset") or "veryfast"
crf = sysconfig.get_int(db, "plex_transcode_crf")
max_height = sysconfig.get_int(db, "plex_transcode_max_height")
audio_langs = _probe_audio_langs(src) if multi else [] audio_langs = _probe_audio_langs(src) if multi else []
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
# Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode can't
# starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a platform
# without fork (the API always runs in a Linux container in prod) it's simply omitted.
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
with _lock: with _lock:
old = _sessions.pop(key, None) old = _sessions.pop(key, None)
if old is not None: if old is not None:
@@ -401,9 +455,13 @@ def start_session(
audio_langs if is_multi else None, audio_langs if is_multi else None,
transcode=transcode, transcode=transcode,
hw=hw, hw=hw,
preset=preset,
crf=crf,
max_height=max_height,
), ),
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=preexec,
) )
s = HlsSession( s = HlsSession(
key, key,
+8
View File
@@ -159,6 +159,14 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"), ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.) # (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16), ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
# Transcode re-encode tuning (E6 S1). hw = "auto"|"on"|"off" (free-text, like search_source); the
# libx264 knobs apply only to the software fallback. crf 0–51 (x264 range); max_height 0 = source.
ConfigSpec("plex_transcode_hw", "str", "plex", "plex_transcode_hw"),
ConfigSpec("plex_transcode_preset", "str", "plex", "plex_transcode_preset"),
ConfigSpec("plex_transcode_crf", "int", "plex", "plex_transcode_crf", min=0, max=51),
ConfigSpec(
"plex_transcode_max_height", "int", "plex", "plex_transcode_max_height", min=0, max=4320
),
# NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job # NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job
# (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS. # (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS.
# --- TMDB enrichment (E5 S2; optional) — the API key is the only secret --- # --- TMDB enrichment (E5 S2; optional) — the API key is the only secret ---
+58 -8
View File
@@ -228,27 +228,77 @@ class TestTranscodeCommand:
assert "-var_stream_map" in cmd # …but still multi-rendition (client-side audio switch) assert "-var_stream_map" in cmd # …but still multi-rendition (client-side audio switch)
assert "copy" not in cmd assert "copy" not in cmd
def test_software_preset_and_crf_come_from_the_knobs(self):
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, preset="slow", crf=18
)
assert cmd[cmd.index("-preset") + 1] == "slow"
assert cmd[cmd.index("-crf") + 1] == "18"
def test_software_max_height_caps_with_a_downscale_only_filter(self):
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, max_height=720
)
# Downscale-only, aspect-preserving, even width; the comma in min() escaped so it's ONE filter.
assert cmd[cmd.index("-vf") + 1] == "scale=-2:min(ih\\,720)"
def test_software_max_height_zero_means_no_scale_filter(self):
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, max_height=0
)
assert "-vf" not in cmd # source resolution kept
def test_hardware_ignores_max_height(self):
# The GPU encode is cheap, so it runs at source resolution; max_height is a software-only knob.
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=True, max_height=720
)
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # not a software scale
class TestWantHw: class TestWantHw:
"""VAAPI is used only where the render node was passed in AND the source is HW-decodable.""" """VAAPI is used only where the render node was passed in AND (in auto) the source is HW-decodable,
with an admin `plex_transcode_hw` policy (auto/on/off) on top."""
@staticmethod
def _policy(monkeypatch, mode):
monkeypatch.setattr(stream.sysconfig, "get_str", lambda db, k: mode)
def test_no_hw_when_not_transcoding(self, monkeypatch): def test_no_hw_when_not_transcoding(self, monkeypatch):
self._policy(monkeypatch, "auto")
monkeypatch.setattr(stream, "_render_node_present", lambda: True) monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(_FakeItem(codec_video="hevc"), transcode=False) is False assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=False) is False
def test_no_hw_without_a_render_node(self, monkeypatch): def test_no_hw_without_a_render_node(self, monkeypatch):
self._policy(monkeypatch, "auto")
monkeypatch.setattr(stream, "_render_node_present", lambda: False) monkeypatch.setattr(stream, "_render_node_present", lambda: False)
assert stream._want_hw(_FakeItem(codec_video="hevc"), transcode=True) is False assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
def test_hw_for_a_decodable_source_with_the_render_node(self, monkeypatch): def test_auto_uses_hw_for_a_decodable_source_with_the_render_node(self, monkeypatch):
self._policy(monkeypatch, "auto")
monkeypatch.setattr(stream, "_render_node_present", lambda: True) monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(_FakeItem(codec_video="hevc"), transcode=True) is True assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is True
def test_software_for_a_non_decodable_source_even_with_the_render_node(self, monkeypatch): def test_auto_falls_back_to_software_for_a_non_decodable_source(self, monkeypatch):
# AV1 / MPEG-4 ASP (XviD) aren't decodable on Gen 9.5 → the GPU can't feed its own encoder. # AV1 / MPEG-4 ASP (XviD) aren't decodable on Gen 9.5 → the GPU can't feed its own encoder.
self._policy(monkeypatch, "auto")
monkeypatch.setattr(stream, "_render_node_present", lambda: True) monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(_FakeItem(codec_video="av1"), transcode=True) is False assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is False
assert stream._want_hw(_FakeItem(codec_video="mpeg4"), transcode=True) is False assert stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True) is False
def test_off_forces_software_even_for_a_decodable_source(self, monkeypatch):
self._policy(monkeypatch, "off")
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
def test_on_forces_hw_even_for_a_non_decodable_source(self, monkeypatch):
# The admin override trusts the GPU to decode it; skips the decodability gate (render node still
# required — HW is impossible without it).
self._policy(monkeypatch, "on")
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is True
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
class TestColdStartWarmup: class TestColdStartWarmup:
+17 -1
View File
@@ -166,7 +166,23 @@
}, },
"plex_max_transcodes": { "plex_max_transcodes": {
"label": "Max concurrent transcodes", "label": "Max concurrent transcodes",
"hint": "Cap on simultaneous CPU transcodes for incompatible files (a later fallback). Direct-playable files aren't limited." "hint": "Cap on simultaneous transcode sessions for non-H.264 files. Direct-play and remux (stream-copy) files aren't limited."
},
"plex_transcode_hw": {
"label": "Hardware transcode",
"hint": "GPU (VAAPI) encoding policy: auto (GPU when available and the source is decodable, else software), on (force GPU), off (always software)."
},
"plex_transcode_preset": {
"label": "Software preset",
"hint": "libx264 preset for the software fallback (e.g. veryfast, faster, medium). Slower presets shrink the stream but cost more CPU. Ignored on the GPU path."
},
"plex_transcode_crf": {
"label": "Software quality (CRF)",
"hint": "libx264 CRF for the software fallback (0–51; lower = better quality, larger). 20 is a good default. Ignored on the GPU path."
},
"plex_transcode_max_height": {
"label": "Software max height",
"hint": "Cap the software transcode's output height in pixels (e.g. 1080; 0 = keep source) so a tall source stays affordable to re-encode. The GPU path keeps source resolution."
} }
}, },
"save": "Save", "save": "Save",
+17 -1
View File
@@ -166,7 +166,23 @@
}, },
"plex_max_transcodes": { "plex_max_transcodes": {
"label": "Max párhuzamos transcode", "label": "Max párhuzamos transcode",
"hint": "A nem kompatibilis fájlok egyidejű CPU-transcode-jainak felső korlátja (későbbi tartalék). A direktben játszható fájlokra nincs korlát." "hint": "A nem-H.264 fájlok egyidejű transcode-munkameneteinek felső korlátja. A direktben játszható és a remux (stream-copy) fájlokra nincs korlát."
},
"plex_transcode_hw": {
"label": "Hardveres transcode",
"hint": "GPU (VAAPI) kódolási házirend: auto (GPU, ha elérhető és a forrás dekódolható, különben szoftver), on (GPU kényszerítése), off (mindig szoftver)."
},
"plex_transcode_preset": {
"label": "Szoftveres preset",
"hint": "libx264 preset a szoftveres tartalékhoz (pl. veryfast, faster, medium). A lassabb preset kisebb streamet ad, de több CPU-t eszik. A GPU-úton figyelmen kívül marad."
},
"plex_transcode_crf": {
"label": "Szoftveres minőség (CRF)",
"hint": "libx264 CRF a szoftveres tartalékhoz (0–51; alacsonyabb = jobb minőség, nagyobb méret). A 20 jó alapérték. A GPU-úton figyelmen kívül marad."
},
"plex_transcode_max_height": {
"label": "Szoftveres max. magasság",
"hint": "A szoftveres transcode kimeneti magasságának korlátja képpontban (pl. 1080; 0 = forrás megtartása), hogy egy magas forrás újrakódolása megfizethető maradjon. A GPU-út a forrásfelbontást tartja."
} }
}, },
"save": "Mentés", "save": "Mentés",