From 3f0b38d513635169c3a681a48a2522abe14395ac Mon Sep 17 00:00:00 2001 From: PickleRick Date: Sun, 20 Sep 2026 19:37:31 +0200 Subject: [PATCH] fix: update GOP parameters for mediaAnalyzerGOP and transcode2PassX265 to enhance encoding efficiency and documentation --- .../utility/mediaAnalyzerGOP/1.0.0/index.js | 18 +++++++++++++----- .../video/transcode2PassX265/1.0.0/index.js | 10 ++++++---- README.md | 6 +++--- docs/integration-guide.md | 1 + tests/quick-logic-test.js | 8 +++++--- tests/test-cases/test1-movie-23976fps.js | 7 +++++-- tests/test-cases/test2-anime-25fps.js | 7 +++++-- 7 files changed, 38 insertions(+), 19 deletions(-) diff --git a/LocalFlowPlugins/utility/mediaAnalyzerGOP/1.0.0/index.js b/LocalFlowPlugins/utility/mediaAnalyzerGOP/1.0.0/index.js index d03572e..5b7779e 100644 --- a/LocalFlowPlugins/utility/mediaAnalyzerGOP/1.0.0/index.js +++ b/LocalFlowPlugins/utility/mediaAnalyzerGOP/1.0.0/index.js @@ -6,7 +6,9 @@ * mediaAnalyzerGOP * Analyzes the video stream and populates args.variables with encoding * parameters consumed by downstream plugins: - * gopKeyint / gopMinKeyint -> 2 x FPS (rounded) + * gopKeyint -> 4 x FPS (rounded; maximum 4-second GOP) + * gopMinKeyint -> 1 x FPS (rounded; permits scene cuts) + * gopSceneCut -> x265 scene-cut threshold (40) * isAnimation -> detected from animeSeries path list * isMovie -> detected from moviePathPatterns * vBitrate / vMaxRate / vBufSize @@ -17,7 +19,7 @@ const details = () => ({ name: 'Media Analyzer & GOP Calculator', description: - 'Analyzes video metadata, calculates GOP keyint (2xFPS), target bitrate ' + 'Analyzes video metadata, calculates a streaming-friendly 4-second GOP, target bitrate ' + 'and VBV parameters based on content type (film/series/anime). ' + 'Exports all values to args.variables for downstream plugins.', style: { borderColor: '#2196F3' }, @@ -123,8 +125,13 @@ const plugin = async (args) => { } jobLog(`[mediaAnalyzerGOP] FPS: ${fps.toFixed(3)}\n`); - const gopKeyint = String(Math.round(fps * 2)); - jobLog(`[mediaAnalyzerGOP] keyint=${gopKeyint}\n`); + // A four-second maximum GOP is a compromise for remote streaming: it + // reduces regular I-frame overhead while keeping seek recovery short. + // A one-second minimum leaves x265 free to insert an I-frame at a real cut. + const gopKeyint = String(Math.round(fps * 4)); + const gopMinKeyint = String(Math.round(fps)); + const gopSceneCut = '40'; + jobLog(`[mediaAnalyzerGOP] keyint=${gopKeyint} min-keyint=${gopMinKeyint} scenecut=${gopSceneCut}\n`); // Normalize to forward slashes for cross-platform path matching const normalizedPath = filePath.replace(/\\/g, '/'); @@ -149,7 +156,8 @@ const plugin = async (args) => { jobLog(`[mediaAnalyzerGOP] vBitrate=${vBitrate} vMaxRate=${vMaxRate} vBufSize=${vBufSize}\n`); variables.gopKeyint = gopKeyint; - variables.gopMinKeyint = gopKeyint; + variables.gopMinKeyint = gopMinKeyint; + variables.gopSceneCut = gopSceneCut; variables.isAnimation = String(isAnimation); variables.isMovie = String(isMovie); variables.vBitrate = vBitrate; diff --git a/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js b/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js index d736014..9005fd8 100644 --- a/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js +++ b/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js @@ -166,8 +166,9 @@ const plugin = async (args) => { // ── Read variables ──────────────────────────────────────────────────────── const { - gopKeyint = '48', - gopMinKeyint = '48', + gopKeyint = '96', + gopMinKeyint = '24', + gopSceneCut = '40', vBitrate = '3000k', vMaxRate = '6000k', vBufSize = '12000k', @@ -189,7 +190,8 @@ const plugin = async (args) => { jobLog( `[transcode2PassX265] Params: bitrate=${vBitrate} maxrate=${maxRateNum}k ` - + `bufsize=${bufSizeNum}k keyint=${gopKeyint} tune=${x265Tune || 'none'} preset=${x265Preset}\n`, + + `bufsize=${bufSizeNum}k keyint=${gopKeyint} min-keyint=${gopMinKeyint} scenecut=${gopSceneCut} ` + + `tune=${x265Tune || 'none'} preset=${x265Preset}\n`, ); const sourceBitrateKbps = getVideoBitrateKbps(videoStream, streams, format).toFixed(0); jobLog( @@ -219,7 +221,7 @@ const plugin = async (args) => { const x265ParamsBase = [ `keyint=${gopKeyint}`, `min-keyint=${gopMinKeyint}`, - `scenecut=0`, + `scenecut=${gopSceneCut}`, `open-gop=0`, `vbv-maxrate=${maxRateNum}`, `vbv-bufsize=${bufSizeNum}`, diff --git a/README.md b/README.md index ad99883..d41d13f 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Reads video stream metadata and populates `args.variables` for all downstream pl **Outputs**: `1` → continue -**Variables set**: `gopKeyint`, `gopMinKeyint`, `isAnimation`, `isMovie`, `vBitrate`, `vMaxRate`, `vBufSize`, `x265Tune`, `videoHeight`, `videoFps` +**Variables set**: `gopKeyint` (4 s max GOP), `gopMinKeyint` (1 s), `gopSceneCut` (40), `isAnimation`, `isMovie`, `vBitrate`, `vMaxRate`, `vBufSize`, `x265Tune`, `videoHeight`, `videoFps` --- @@ -237,14 +237,14 @@ Performs 2-pass libx265 encoding using VBV parameters from `args.variables`. **Outputs**: `1` → transcode complete -**Reads from variables**: `gopKeyint`, `gopMinKeyint`, `vBitrate`, `vMaxRate`, `vBufSize`, `x265Tune`, `audioHasIta`, `audioItaStreamIndex`, `audioItaOpusBitrate`, `audioHasEng`, `audioEngStreamIndex`, `audioEngOpusBitrate` +**Reads from variables**: `gopKeyint`, `gopMinKeyint`, `gopSceneCut`, `vBitrate`, `vMaxRate`, `vBufSize`, `x265Tune`, `audioHasIta`, `audioItaStreamIndex`, `audioItaOpusBitrate`, `audioHasEng`, `audioEngStreamIndex`, `audioEngOpusBitrate` --- ## 🔗 Flow Variable Map ``` -mediaAnalyzerGOP ──► gopKeyint, gopMinKeyint, vBitrate, vMaxRate, vBufSize +mediaAnalyzerGOP ──► gopKeyint, gopMinKeyint, gopSceneCut, vBitrate, vMaxRate, vBufSize ──► isAnimation, isMovie, x265Tune, videoHeight, videoFps │ (optional) setAnimationFlag ──► overrides vBitrate, vMaxRate, vBufSize, x265Tune diff --git a/docs/integration-guide.md b/docs/integration-guide.md index a8786e3..d8c85e7 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -165,6 +165,7 @@ Questa tabella mostra tutte le variabili che i plugin si passano a vicenda: |---|---|---| | `gopKeyint` | mediaAnalyzerGOP | transcode2PassX265 | | `gopMinKeyint` | mediaAnalyzerGOP | transcode2PassX265 | +| `gopSceneCut` | mediaAnalyzerGOP | transcode2PassX265 | | `isAnimation` | mediaAnalyzerGOP / setAnimationFlag | audioSelectorClamping | | `isMovie` | mediaAnalyzerGOP | audioSelectorClamping | | `vBitrate` | mediaAnalyzerGOP / setAnimationFlag | transcode2PassX265 | diff --git a/tests/quick-logic-test.js b/tests/quick-logic-test.js index 671dd06..7aeaa29 100644 --- a/tests/quick-logic-test.js +++ b/tests/quick-logic-test.js @@ -52,7 +52,9 @@ const AUDIO_INPUTS = { { const a = makeArgs('/media/toshiba/Radarr/Film (2023)/Film.mkv', [makeStream({ r_frame_rate:'24000/1001', height:1080 })], ANALYZER_INPUTS); const o = await analyzerPlugin(a); - eq(o.variables.gopKeyint, '48', '23.976fps -> keyint=48'); + eq(o.variables.gopKeyint, '96', '23.976fps -> keyint=96 (4 seconds)'); + eq(o.variables.gopMinKeyint, '24', '23.976fps -> min-keyint=24 (1 second)'); + eq(o.variables.gopSceneCut, '40', 'scene-cut threshold is 40'); eq(o.variables.isMovie, 'true', '/Radarr -> isMovie=true'); eq(o.variables.isAnimation, 'false', 'no anime -> isAnimation=false'); eq(o.variables.vBitrate, '3000k', 'film bitrate=3000k'); @@ -64,7 +66,7 @@ const AUDIO_INPUTS = { { const a = makeArgs('/media/toshiba/Sonarr/Naruto S01/S01E01.mkv', [makeStream({ r_frame_rate:'25/1', height:720 })], ANALYZER_INPUTS); const o = await analyzerPlugin(a); - eq(o.variables.gopKeyint, '50', '25fps -> keyint=50'); + eq(o.variables.gopKeyint, '100', '25fps -> keyint=100 (4 seconds)'); eq(o.variables.isMovie, 'false', '/Sonarr -> isMovie=false'); eq(o.variables.isAnimation, 'true', 'Naruto -> isAnimation=true'); eq(o.variables.x265Tune, 'animation', 'anime -> tune=animation'); @@ -74,7 +76,7 @@ const AUDIO_INPUTS = { { const a = makeArgs('/media/toshiba/Sonarr/Naruto S01/S01E01.mkv', [makeStream({ r_frame_rate:'30000/1001', height:480 })], ANALYZER_INPUTS); const o = await analyzerPlugin(a); - eq(o.variables.gopKeyint, '60', '29.97fps -> keyint=60'); + eq(o.variables.gopKeyint, '120', '29.97fps -> keyint=120 (4 seconds)'); eq(o.variables.vBitrate, '1000k', 'anime SD 480p -> 1000k'); cleanup(a); } diff --git a/tests/test-cases/test1-movie-23976fps.js b/tests/test-cases/test1-movie-23976fps.js index e804739..67fa8da 100644 --- a/tests/test-cases/test1-movie-23976fps.js +++ b/tests/test-cases/test1-movie-23976fps.js @@ -6,7 +6,8 @@ * File: Radarr path, AC3 ITA 448k 5.1, EAC3 ENG 640k 5.1 * * Expected: - * gopKeyint = "48" + * gopKeyint = "96" + * gopMinKeyint = "24" * isMovie = "true" * isAnimation = "false" * vBitrate = "3000k" @@ -44,7 +45,9 @@ async function run() { const vars = out1.variables; assertEqual(out1.outputNumber, 1, 'outputNumber'); - assertEqual(vars.gopKeyint, '48', 'gopKeyint (2 x 23.976 = 47.95 -> 48)'); + assertEqual(vars.gopKeyint, '96', 'gopKeyint (4 x 23.976 = 95.90 -> 96)'); + assertEqual(vars.gopMinKeyint,'24', 'gopMinKeyint (1 x 23.976 = 23.98 -> 24)'); + assertEqual(vars.gopSceneCut, '40', 'gopSceneCut (x265 default threshold)'); assertEqual(vars.isMovie, 'true', 'isMovie (Radarr path)'); assertEqual(vars.isAnimation, 'false', 'isAnimation (no anime match)'); assertEqual(vars.vBitrate, '3000k', 'vBitrate (film default)'); diff --git a/tests/test-cases/test2-anime-25fps.js b/tests/test-cases/test2-anime-25fps.js index 0883bd5..8425a8b 100644 --- a/tests/test-cases/test2-anime-25fps.js +++ b/tests/test-cases/test2-anime-25fps.js @@ -6,7 +6,8 @@ * Note: DTS detection logic is fully covered in unit tests (quick-logic-test.js). * * Expected: - * gopKeyint = "50" + * gopKeyint = "100" + * gopMinKeyint = "25" * isAnimation = "true" * isMovie = "false" * x265Tune = "animation" @@ -40,7 +41,9 @@ async function run() { }); const out1 = await analyzerPlugin(args1); const vars = out1.variables; - assertEqual(vars.gopKeyint, '50', 'gopKeyint (2 x 25 = 50)'); + assertEqual(vars.gopKeyint, '100', 'gopKeyint (4 x 25 = 100)'); + assertEqual(vars.gopMinKeyint,'25', 'gopMinKeyint (1 x 25 = 25)'); + assertEqual(vars.gopSceneCut, '40', 'gopSceneCut (x265 default threshold)'); assertEqual(vars.isMovie, 'false', 'isMovie=false (Sonarr path)'); assertEqual(vars.isAnimation, 'true', 'isAnimation=true (Naruto in path)'); assertEqual(vars.x265Tune, 'animation', 'x265Tune=animation');