feat: Tdarr Local Flow Plugin Suite v1.0
7 atomic plugins for automated x265 transcoding pipeline: - utility/mediaAnalyzerGOP : GOP calc, FPS detection, film/series/anime routing - utility/setAnimationFlag : manual animation flag override - utility/extractChaptersXml : mkvextract chapters to XML - audio/audioSelectorClamping : codec scoring, clamped Opus bitrates, 2 outputs - audio/backupSourceAudio : lossless DTS/TrueHD ITA backup (-c:a copy) - subtitles/extractSubtitlesAndOcr : SRT extract + SubtitleEdit OCR for PGS - video/transcode2PassX265 : 2-pass x265, Lanczos 1080p bound, Opus mux Test suite: 34 unit tests + 3 integration tests (FFmpeg real files) FlowHelpers mock for local testing included Docs: integration-guide.md with canvas wiring and deploy instructions
This commit is contained in:
commit
c2074be826
19 files changed
+2134
No files matched your search
@@ -0,0 +1,206 @@
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
/**
|
||||
* audioSelectorClamping
|
||||
* Selects the best ITA and ENG audio tracks using codec+bitrate scoring,
|
||||
* then calculates clamped Opus target bitrates.
|
||||
*
|
||||
* Scoring: score = (codecWeight * 10000) + bitrateKbps
|
||||
* Weights: TrueHD=100, DTS-HD MA=95, FLAC=90, EAC3=75, DTS=60,
|
||||
* AC3=45, AAC=35, Opus=30
|
||||
*
|
||||
* Clamping:
|
||||
* Multichannel (ch > 2):
|
||||
* Film: min(filmMultiChannelBitrate, sourceBitrateKbps)
|
||||
* Series: sourceBitrateKbps >= threshold ? 384 : 256
|
||||
* Stereo (ch <= 2):
|
||||
* min(stereoBitrateMax, sourceBitrateKbps) [no upcoding]
|
||||
*/
|
||||
|
||||
const CODEC_WEIGHTS = {
|
||||
truehd: 100,
|
||||
flac: 90,
|
||||
eac3: 75,
|
||||
dts: 60,
|
||||
ac3: 45,
|
||||
aac: 35,
|
||||
opus: 30,
|
||||
mp3: 20,
|
||||
vorbis: 20,
|
||||
};
|
||||
|
||||
const getCodecWeight = (stream) => {
|
||||
const codec = (stream.codec_name || '').toLowerCase();
|
||||
const profile = (stream.profile || '').toLowerCase();
|
||||
// DTS-HD MA detection via profile field
|
||||
if (codec === 'dts' && (profile.includes('ma') || profile.includes('dts-hd'))) return 95;
|
||||
return CODEC_WEIGHTS[codec] || 5;
|
||||
};
|
||||
|
||||
const getSourceBitrateKbps = (stream) => {
|
||||
const br = parseInt(stream.bit_rate || '0', 10);
|
||||
return br > 0 ? Math.round(br / 1000) : 0;
|
||||
};
|
||||
|
||||
const calcOpusBitrate = (stream, isMovie, inputs) => {
|
||||
const srcKbps = getSourceBitrateKbps(stream);
|
||||
const ch = parseInt(stream.channels || '2', 10);
|
||||
const stereoMax = parseInt(inputs.stereoBitrateMax || '128', 10);
|
||||
const filmTarget = parseInt(inputs.filmMultiChannelBitrate || '640', 10);
|
||||
const serThreshold = parseInt(inputs.seriesMultiChannelThreshold || '384', 10);
|
||||
|
||||
if (ch <= 2) {
|
||||
// Stereo: min(max, source) — no upcoding
|
||||
return srcKbps > 0 ? Math.min(stereoMax, srcKbps) : stereoMax;
|
||||
}
|
||||
|
||||
// Multichannel
|
||||
if (isMovie) {
|
||||
return srcKbps > 0 ? Math.min(filmTarget, srcKbps) : filmTarget;
|
||||
}
|
||||
// Series
|
||||
// If bitrate is unknown (0), assume high-quality source -> use 384k
|
||||
const effectiveKbps = srcKbps > 0 ? srcKbps : serThreshold;
|
||||
return effectiveKbps >= serThreshold ? 384 : 256;
|
||||
};
|
||||
|
||||
const details = () => ({
|
||||
name: 'Audio Selector & Opus Bitrate Clamping',
|
||||
description:
|
||||
'Selects the best ITA and ENG audio tracks using weighted codec+bitrate scoring. '
|
||||
+ 'Calculates clamped Opus target bitrates. '
|
||||
+ 'Output 1: tracks found. Output 2: no ITA or ENG tracks found.',
|
||||
style: { borderColor: '#4CAF50' },
|
||||
tags: 'audio',
|
||||
isStartPlugin: false,
|
||||
pType: '',
|
||||
requiresVersion: '2.11.01',
|
||||
sidebarPosition: 2,
|
||||
icon: 'faVolumeUp',
|
||||
inputs: [
|
||||
{
|
||||
label: 'Film Multichannel Bitrate (kbps)',
|
||||
name: 'filmMultiChannelBitrate',
|
||||
type: 'string',
|
||||
defaultValue: '640',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Max Opus bitrate in kbps for multichannel (>2ch) Film audio. Source bitrate is used if lower.',
|
||||
},
|
||||
{
|
||||
label: 'Series Multichannel Threshold (kbps)',
|
||||
name: 'seriesMultiChannelThreshold',
|
||||
type: 'string',
|
||||
defaultValue: '384',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'For series: if source bitrate >= this value, Opus target is 384k, otherwise 256k.',
|
||||
},
|
||||
{
|
||||
label: 'Stereo Max Bitrate (kbps)',
|
||||
name: 'stereoBitrateMax',
|
||||
type: 'string',
|
||||
defaultValue: '128',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Max Opus bitrate for stereo tracks. Source bitrate is used if lower (no upcoding).',
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{ number: 1, tooltip: 'ITA or ENG track found — continue pipeline' },
|
||||
{ number: 2, tooltip: 'No ITA or ENG tracks found — handle separately' },
|
||||
],
|
||||
});
|
||||
|
||||
const plugin = async (args) => {
|
||||
const { jobLog, inputFileObj, variables } = args;
|
||||
|
||||
const filePath = inputFileObj._id;
|
||||
jobLog(`[audioSelectorClamping] Processing: ${filePath}\n`);
|
||||
|
||||
const isMovie = variables.isMovie === 'true';
|
||||
jobLog(`[audioSelectorClamping] isMovie=${isMovie}\n`);
|
||||
|
||||
const streams = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.streams) || [];
|
||||
const audioStreams = streams.filter((s) => s.codec_type === 'audio');
|
||||
|
||||
jobLog(`[audioSelectorClamping] Found ${audioStreams.length} audio stream(s).\n`);
|
||||
|
||||
let bestIta = null;
|
||||
let bestEng = null;
|
||||
let bestItaScore = -1;
|
||||
let bestEngScore = -1;
|
||||
|
||||
audioStreams.forEach((stream) => {
|
||||
const lang = ((stream.tags && stream.tags.language) || '').toLowerCase();
|
||||
if (lang !== 'ita' && lang !== 'eng' && lang !== 'it' && lang !== 'en') return;
|
||||
|
||||
const normLang = (lang === 'it') ? 'ita' : (lang === 'en') ? 'eng' : lang;
|
||||
const weight = getCodecWeight(stream);
|
||||
const srcKbps = getSourceBitrateKbps(stream);
|
||||
const score = weight * 10000 + srcKbps;
|
||||
|
||||
jobLog(
|
||||
`[audioSelectorClamping] Stream #${stream.index} lang=${normLang} `
|
||||
+ `codec=${stream.codec_name} profile="${stream.profile || ''}" `
|
||||
+ `src=${srcKbps}kbps ch=${stream.channels} score=${score}\n`,
|
||||
);
|
||||
|
||||
if (normLang === 'ita' && score > bestItaScore) {
|
||||
bestItaScore = score;
|
||||
bestIta = { stream, normLang };
|
||||
}
|
||||
if (normLang === 'eng' && score > bestEngScore) {
|
||||
bestEngScore = score;
|
||||
bestEng = { stream, normLang };
|
||||
}
|
||||
});
|
||||
|
||||
if (!bestIta && !bestEng) {
|
||||
jobLog('[audioSelectorClamping] WARNING: No ITA or ENG tracks found. Routing to output 2.\n');
|
||||
variables.audioHasIta = 'false';
|
||||
variables.audioHasEng = 'false';
|
||||
return { outputNumber: 2, outputFileObj: inputFileObj, variables };
|
||||
}
|
||||
|
||||
// Export ITA
|
||||
if (bestIta) {
|
||||
const { stream } = bestIta;
|
||||
const opusBitrate = calcOpusBitrate(stream, isMovie, args.inputs);
|
||||
variables.audioHasIta = 'true';
|
||||
variables.audioItaStreamIndex = String(stream.index);
|
||||
variables.audioItaOpusBitrate = String(opusBitrate);
|
||||
variables.audioItaChannels = String(stream.channels || '2');
|
||||
variables.audioItaCodecName = (stream.codec_name || '').toLowerCase();
|
||||
jobLog(
|
||||
`[audioSelectorClamping] ITA selected: #${stream.index} ${stream.codec_name} `
|
||||
+ `${getSourceBitrateKbps(stream)}kbps -> Opus ${opusBitrate}kbps\n`,
|
||||
);
|
||||
} else {
|
||||
variables.audioHasIta = 'false';
|
||||
jobLog('[audioSelectorClamping] No ITA track found.\n');
|
||||
}
|
||||
|
||||
// Export ENG
|
||||
if (bestEng) {
|
||||
const { stream } = bestEng;
|
||||
const opusBitrate = calcOpusBitrate(stream, isMovie, args.inputs);
|
||||
variables.audioHasEng = 'true';
|
||||
variables.audioEngStreamIndex = String(stream.index);
|
||||
variables.audioEngOpusBitrate = String(opusBitrate);
|
||||
variables.audioEngChannels = String(stream.channels || '2');
|
||||
variables.audioEngCodecName = (stream.codec_name || '').toLowerCase();
|
||||
jobLog(
|
||||
`[audioSelectorClamping] ENG selected: #${stream.index} ${stream.codec_name} `
|
||||
+ `${getSourceBitrateKbps(stream)}kbps -> Opus ${opusBitrate}kbps\n`,
|
||||
);
|
||||
} else {
|
||||
variables.audioHasEng = 'false';
|
||||
jobLog('[audioSelectorClamping] No ENG track found.\n');
|
||||
}
|
||||
|
||||
jobLog('[audioSelectorClamping] Done.\n');
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
};
|
||||
|
||||
module.exports.details = details;
|
||||
module.exports.plugin = plugin;
|
||||
Reference in new issue
Block a user