@eleven-am/transcoder
Version:
High-performance HLS transcoding library with hardware acceleration, intelligent client management, and distributed processing support for Node.js
647 lines • 24.9 kB
JavaScript
;
/*
* @eleven-am/transcoder
* Copyright (C) 2025 Roy OSSAI
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.MetadataService = void 0;
const child_process_1 = require("child_process");
const path = __importStar(require("path"));
const fp_1 = require("@eleven-am/fp");
const types_1 = require("./types");
class MetadataService {
constructor(qualityService, databaseConnector) {
this.qualityService = qualityService;
this.databaseConnector = databaseConnector;
}
/**
* Get metadata for a file, extracting it if necessary
* @param source The media source to get metadata from
*/
getMetadata(source) {
return source.getFileId()
.fromPromise((fileId) => this.databaseConnector.metadataExists(fileId))
.matchTask([
{
predicate: ({ exists }) => exists,
run: ({ fileId }) => fp_1.TaskEither
.tryCatch(() => this.databaseConnector.getMetadata(fileId)),
},
{
predicate: ({ exists }) => !exists,
run: ({ fileId }) => this.extractMetadataWithLock(fileId, source),
},
]);
}
/**
* Detect the best codec for the given source video
* This is especially important for CUDA which has specific decoders per codec
* @param source The media source to detect the codec from
* @param hwConfig Hardware acceleration configuration
* @returns A TaskEither with the updated hardware acceleration configuration
*/
detectOptimalCodecConfig(source, hwConfig) {
const args = [
'-hide_banner',
'-select_streams',
'v:0',
'-show_entries',
'stream=codec_name',
'-of',
'default=noprint_wrappers=1:nokey=1',
source.getFilePath(),
];
return fp_1.TaskEither
.fromNullable(hwConfig)
.filter((hwConfig) => hwConfig.method === types_1.HardwareAccelerationMethod.CUDA, () => (0, fp_1.createBadRequestError)('Hardware acceleration is not enabled or not CUDA'))
.chain((hwConfig) => this.probe(args).map((codec) => ({
codec: codec.trim(),
hwConfig,
})))
.map(({ codec, hwConfig }) => {
if (codec === 'mpeg1video') {
return ['mpeg1_cuvid', hwConfig];
}
else if (codec === 'mpeg2video') {
return ['mpeg2_cuvid', hwConfig];
}
return [`${codec}_cuvid`, hwConfig];
})
.map(([decoderName, hwConfig]) => ({
...hwConfig,
inputOptions: [
...hwConfig.inputOptions,
'-c:v',
decoderName,
],
}))
.orElse(() => fp_1.TaskEither.of(hwConfig));
}
/**
* Get the master playlist for the media file
* @returns The master playlist as a string
*/
getMasterPlaylist(source) {
const genPlaylist = (metadata) => {
const videoRenditions = metadata.videos.map((x) => this.generateVideoRenditionEntries(x));
const master = [
'#EXTM3U',
...metadata.audios.map((x) => this.generateAudioRenditionEntries(x)),
'',
...videoRenditions.map(({ mediaTypes }) => mediaTypes),
'',
...videoRenditions.map(({ mappedProfile }) => mappedProfile),
];
const defaultAudio = metadata.audios.find((audio) => audio.isDefault);
const defaultVideo = metadata.videos.find((video) => video.isDefault);
const audioQuality = defaultAudio
? {
index: defaultAudio.index,
quality: this.isAudioHLSCompatible(defaultAudio) ? types_1.AudioQualityEnum.ORIGINAL : types_1.AudioQualityEnum.AAC,
}
: {
index: 0,
quality: types_1.AudioQualityEnum.AAC,
};
const videoQuality = defaultVideo
? {
index: defaultVideo.index,
quality: this.isVideoHLSCompatible(defaultVideo) ? types_1.VideoQualityEnum.ORIGINAL : types_1.VideoQualityEnum.P240,
}
: {
index: 0,
quality: types_1.VideoQualityEnum.P240,
};
const string = master.join('\n');
return {
master: string,
video: videoQuality,
audio: audioQuality,
};
};
return this.getMetadata(source).map((metadata) => genPlaylist(metadata));
}
/**
* Create metadata for a file
* @param source Media source to create metadata for
* @returns TaskEither containing the created metadata
*/
createMetadata(source) {
return this.getMetadata(source).map(() => undefined);
}
/**
* Extract metadata
* @param fileId Unique identifier for the media file
* @param source Media source to extract metadata from
*/
extractMetadataWithLock(fileId, source) {
return this.extractMediaInfo(fileId, source)
.fromPromise((metadata) => this.databaseConnector.saveMetadata(fileId, metadata));
}
/**
* Wait for another node to finish extracting metadata
* @param fileId The file ID to wait for
*/
waitForMetadata(fileId) {
const maxWaitTime = 300000;
const checkInterval = 1000;
const startTime = Date.now();
const checkMetadata = () => fp_1.TaskEither
.tryCatch(() => this.databaseConnector.metadataExists(fileId))
.matchTask([
{
predicate: ({ exists }) => exists,
run: ({ fileId }) => fp_1.TaskEither
.tryCatch(() => this.databaseConnector.getMetadata(fileId)),
},
{
predicate: () => (Date.now() - startTime) > maxWaitTime,
run: () => fp_1.TaskEither.error((0, fp_1.createBadRequestError)('Timeout waiting for metadata extraction')),
},
{
predicate: ({ exists }) => !exists,
run: () => fp_1.TaskEither
.tryCatch(() => new Promise((resolve) => setTimeout(resolve, checkInterval)))
.chain(() => checkMetadata()),
},
]);
return checkMetadata();
}
/**
* Extract keyframes from a file using ffprobe
* @param source Media source to extract keyframes from
* @param videoIndex Index of the video stream
* @returns TaskEither containing keyframe data
*/
extractKeyframes(source, videoIndex) {
const args = [
'-loglevel',
'error',
'-analyzeduration',
'100000000',
'-probesize',
'100000000',
'-select_streams',
`v:${videoIndex}`,
'-show_entries',
'packet=pts_time,flags',
'-fflags',
'+genpts',
'-of',
'csv=print_section=0',
source.getFilePath(),
];
return this.probe(args)
.map((output) => output.split('\n'))
.mapItems((line) => line.trim())
.filterItems((line) => line.length > 0)
.mapItems((line) => line.split(','))
.filterItems(([pts, flags]) => pts !== 'N/A' && Boolean(flags) && flags.includes('K'))
.mapItems(([pts]) => parseFloat(pts))
.filterItems((timestamp) => !isNaN(timestamp) && timestamp >= 0)
.map((keyframes) => {
if (keyframes.length > 0 && keyframes[0] > 0) {
keyframes.unshift(0);
}
keyframes.sort((a, b) => a - b);
return keyframes;
});
}
/**
* Extract media information from a file using ffprobe
* @returns TaskEither containing media metadata
* @param fileId Unique identifier for the media file
* @param source Media source to extract metadata from
*/
extractMediaInfo(fileId, source) {
const filePath = source.getFilePath();
const args = [
'-print_format',
'json',
'-show_format',
'-show_streams',
'-show_chapters',
filePath,
];
return fp_1.TaskEither
.fromBind({
ffprobe: this.probe(args),
keyframes: this.extractKeyframes(source, 0),
})
.map(({ ffprobe, keyframes }) => {
const ffprobeData = JSON.parse(ffprobe);
const mediaInfo = {
keyframes,
id: fileId,
path: filePath,
extension: path.extname(filePath).substring(1),
mimeCodec: null,
duration: parseFloat(ffprobeData.format.duration || '0'),
container: ffprobeData.format.format_name || null,
videos: [],
audios: [],
subtitles: [],
fonts: [],
chapters: [],
extractionTimestamp: new Date(),
};
let videoIndex = 0;
let audioIndex = 0;
let subtitleIndex = 0;
for (const stream of ffprobeData.streams || []) {
if (stream.codec_type === 'video' && !stream.disposition?.attached_pic) {
const videoStream = this.processVideoStream(stream, videoIndex++);
mediaInfo.videos.push(videoStream);
}
else if (stream.codec_type === 'audio') {
const audioStream = this.processAudioStream(stream, audioIndex++);
mediaInfo.audios.push(audioStream);
}
else if (stream.codec_type === 'subtitle') {
const subtitleStream = this.processSubtitleStream(stream, subtitleIndex++);
mediaInfo.subtitles.push(subtitleStream);
}
else if (stream.codec_type === 'attachment') {
if (stream.tags?.filename &&
(stream.tags.filename.endsWith('.ttf') ||
stream.tags.filename.endsWith('.otf'))) {
mediaInfo.fonts.push(stream.tags.filename);
}
}
}
if (ffprobeData.chapters) {
for (const chapter of ffprobeData.chapters) {
if (chapter.tags) {
mediaInfo.chapters.push({
startTime: parseFloat(chapter.start_time),
endTime: parseFloat(chapter.end_time),
name: chapter.tags.title || `Chapter ${chapter.id}`,
type: 'content',
});
}
}
}
this.generateMimeCodec(mediaInfo);
return mediaInfo;
});
}
/**
* Run ffprobe with the given arguments
* @param args Arguments to pass to ffprobe
* @private
*/
probe(args) {
const promise = () => new Promise((resolve, reject) => {
const process = (0, child_process_1.spawn)('ffprobe', args);
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code !== 0) {
reject(new Error(`FFprobe exited with code ${code}: ${stderr}`));
return;
}
resolve(stdout);
});
});
return fp_1.TaskEither.tryCatch(() => promise(), (err) => (0, fp_1.createBadRequestError)(`FFprobe error: ${err.message}`));
}
/**
* Process a video stream from FFprobe output
*/
processVideoStream(stream, index) {
return {
index,
codec: stream.codec_name,
mimeCodec: this.getMimeCodec(stream),
title: stream.tags?.title || null,
language: stream.tags?.language || null,
width: parseInt(stream.width, 10) || 0,
height: parseInt(stream.height, 10) || 0,
bitrate: parseInt(stream.bit_rate ?? 1800000, 10) || 0,
isDefault: Boolean(stream.disposition?.default),
};
}
/**
* Process an audio stream from FFprobe output
*/
processAudioStream(stream, index) {
return {
index,
codec: stream.codec_name,
mimeCodec: this.getMimeCodec(stream),
title: stream.tags?.title || null,
language: stream.tags?.language || null,
bitrate: parseInt(stream.bit_rate, 10) || 0,
isDefault: Boolean(stream.disposition?.default),
isForced: Boolean(stream.disposition?.forced),
channels: parseInt(stream.channels, 10) || 2,
};
}
/**
* Process a subtitle stream from FFprobe output
*/
processSubtitleStream(stream, index) {
const extension = this.getSubtitleExtension(stream.codec_name);
return {
index,
extension,
isExternal: false,
codec: stream.codec_name,
title: stream.tags?.title || null,
isForced: Boolean(stream.disposition?.forced),
language: stream.tags?.language || null,
isDefault: Boolean(stream.disposition?.default),
isHearingImpaired: Boolean(stream.disposition?.hearing_impaired),
};
}
/**
* Generate MIME codec string
*/
generateMimeCodec(mediaInfo) {
if (mediaInfo.videos.length > 0 && mediaInfo.audios.length > 0) {
const videoCodec = mediaInfo.videos[0].mimeCodec;
const audioCodec = mediaInfo.audios[0].mimeCodec;
if (videoCodec && audioCodec) {
mediaInfo.mimeCodec = `video/mp4; codecs="${videoCodec}, ${audioCodec}"`;
}
}
}
/**
* Get MIME codec string for a codec
*/
getMimeCodec(stream) {
switch (stream.codec_name) {
case 'h264': {
let ret = 'avc1';
switch ((stream.profile || '').toLowerCase()) {
case 'high':
ret += '.6400';
break;
case 'main':
ret += '.4D40';
break;
case 'baseline':
ret += '.42E0';
break;
default:
ret += '.4240';
break;
}
ret += (stream.level || 0).toString(16).padStart(2, '0');
return ret;
}
case 'h265':
case 'hevc': {
// The h265 syntax is a bit of a mystery at the time this comment was written.
// This is what I've found through various sources:
// FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN]
let ret = 'hvc1';
if (stream.profile === 'main 10') {
ret += '.2.4';
}
else {
ret += '.1.4';
}
// Note: Go version multiplies by 30 (not 3)
ret += `.L${((stream.level || 0) * 30).toString(16).toUpperCase()
.padStart(2, '0')}.BO`;
return ret;
}
case 'av1': {
// https://aomedia.org/av1/specification/annex-a/
// FORMAT: [codecTag].[profile].[level][tier].[bitDepth]
let ret = 'av01';
switch ((stream.profile || '').toLowerCase()) {
case 'main':
ret += '.0';
break;
case 'high':
ret += '.1';
break;
case 'professional':
ret += '.2';
break;
default:
break;
}
let bitdepth = parseInt(stream.bits_per_raw_sample || '0', 10);
if (bitdepth !== 8 && bitdepth !== 10 && bitdepth !== 12) {
bitdepth = 8;
}
const tierflag = 'M';
ret += `.${(stream.level || 0).toString(16).toUpperCase()
.padStart(2, '0')}${tierflag}.${bitdepth.toString().padStart(2, '0')}`;
return ret;
}
case 'aac': {
let ret = 'mp4a';
switch ((stream.profile || '').toLowerCase()) {
case 'he':
ret += '.40.5';
break;
case 'lc':
ret += '.40.2';
break;
default:
ret += '.40.2';
break;
}
return ret;
}
case 'mp3':
return 'mp4a.40.34';
case 'opus':
return 'Opus';
case 'ac3':
return 'mp4a.a5';
case 'eac3':
return 'mp4a.a6';
case 'flac':
return 'fLaC';
case 'alac':
return 'alac';
default:
return null;
}
}
/**
* Get subtitle extension for a codec
*/
getSubtitleExtension(codec) {
const extensionMap = {
subrip: 'srt',
ass: 'ass',
ssa: 'ass',
mov_text: 'vtt',
webvtt: 'vtt',
dvb_subtitle: null,
hdmv_pgs_subtitle: null,
dvd_subtitle: null,
};
return extensionMap[codec] || null;
}
/**
* Check if the video codec is HLS-compatible for direct playback
*/
isVideoHLSCompatible(video) {
return video.mimeCodec?.toLowerCase().startsWith('avc1') ?? false;
}
/**
* Check if the audio codec is HLS-compatible for direct playback
*/
isAudioHLSCompatible(audio) {
return audio.mimeCodec?.toLowerCase().includes('mp4a.40.2') ??
audio.codec?.toLowerCase() === 'aac';
}
/**
* Generate audio rendition entries for HLS master playlist
* @param audio The audio stream information
* @returns Object containing rendition entries and compatibility info
* @private
*/
generateAudioRenditionEntries(audio) {
const playlist = [
'#EXT-X-MEDIA:TYPE=AUDIO',
'GROUP-ID="audio"',
];
const isCompatible = this.isAudioHLSCompatible(audio);
if (audio.language) {
playlist.push(`LANGUAGE="${audio.language}"`);
if (isCompatible) {
playlist.push(`NAME="${audio.language}"`);
}
else {
playlist.push(`NAME="${audio.language} (AAC)"`);
}
}
if (audio.isDefault) {
playlist.push('DEFAULT=YES');
}
if (isCompatible) {
playlist.push(`CHANNELS="${audio.channels}"`);
playlist.push(`URI="audio/${audio.index}/original/playlist.m3u8"`);
}
else {
playlist.push('CHANNELS="2"');
playlist.push(`URI="audio/${audio.index}/aac/playlist.m3u8"`);
}
return playlist.join(',');
}
/**
* Generate video rendition entries for HLS master playlist
* @param video The video stream information
* @returns Object containing media types and mapped profile
* @private
*/
generateVideoRenditionEntries(video) {
const bitrate = video.bitrate;
const isCompatible = this.isVideoHLSCompatible(video);
let qualities = this.qualityService.getVideoQualities(video);
const original = this.qualityService.getNonTranscodeVideoQualities();
const aspectRatio = video.width / video.height;
const transcodePrefix = 'avc1.6400';
const transcodeCodec = `${transcodePrefix}28`;
const audioCodec = 'mp4a.40.2';
if (isCompatible) {
qualities = qualities.filter((quality) => quality.height !== video.height);
qualities.push(original);
}
const mappedProfile = qualities.map((quality) => {
const defaultQuality = this.qualityService.determineVideoQuality(video);
const width = quality.value !== types_1.VideoQualityEnum.ORIGINAL ?
Math.round(aspectRatio * quality.height + 0.5) :
video.width;
const height = quality.value !== types_1.VideoQualityEnum.ORIGINAL ?
quality.height
: video.height;
const averageBitrate = quality.value === types_1.VideoQualityEnum.ORIGINAL ?
Math.min(Math.floor(bitrate * 0.8), defaultQuality.averageBitrate) :
quality.averageBitrate;
const bandWith = quality.value !== types_1.VideoQualityEnum.ORIGINAL ?
quality.maxBitrate
: Math.min(bitrate, defaultQuality.maxBitrate);
const codec = quality.value !== types_1.VideoQualityEnum.ORIGINAL ?
transcodeCodec
: video.mimeCodec;
const attributes = [
`AVERAGE-BANDWIDTH=${averageBitrate}`,
`BANDWIDTH=${bandWith}`,
`RESOLUTION=${width}x${height}`,
`CODECS="${codec},${audioCodec}"`,
'AUDIO="audio"',
'CLOSED-CAPTIONS=NONE',
];
return [
`#EXT-X-STREAM-INF:${attributes.join(',')}`,
`video/${video.index}/${quality.value}/playlist.m3u8`,
].join('\n');
})
.join('\n');
const mediaTypes = qualities
.map((quality) => [
'#EXT-X-MEDIA:TYPE=VIDEO',
`GROUP-ID="${quality.value}"`,
`NAME="Video ${video.index}"`,
...(video.isDefault ? ['DEFAULT=YES'] : []),
].join(','))
.join('\n');
return {
mediaTypes,
mappedProfile,
};
}
}
exports.MetadataService = MetadataService;
//# sourceMappingURL=metadataService.js.map