This patch adds an audio-only option to PeerTube by means of a new transcoding configuration which creates mp4 files which only contain an audio stream. This new transcoder has a resolution of '0' and is presented in the preferences and in the player resolution menu as 'Audio-only' (localised). When playing such streams the player shows the file thumbnail as background and disables controls autohide.
Audio-only files can be shared and streamed just like any other file. They can be downloaded as well, the resulting file will be an mp4 container with a single audio stream.
This patch is a proof of concept to show the feasibility of 'true' audio-only support. There are better ways of doing this which also enable multiple audio streams for a given video stream (e.g. DASH) but as this would entail a fundamental change in the way PeerTube works it is a bridge too far for a simple proof of concept.
super()
this.resolutions = [
+ {
+ id: '0p',
+ label: this.i18n('Audio-only')
+ },
{
id: '240p',
label: this.i18n('240p')
this.player_,
{
id: d.id,
- label: d.label,
+ label: d.id == 0 ? this.player .localize('Audio-only') : d.label,
selected: d.selected,
callback: data.qualitySwitchCallback
})
const currentTime = this.player.currentTime()
const isPaused = this.player.paused()
- // Remove poster to have black background
- this.playerElement.poster = ''
-
// Hide bigPlayButton
if (!isPaused) {
this.player.bigPlayButton.hide()
}
+ // Audio-only (resolutionId == 0) gets special treatment
+ if (resolutionId > 0) {
+ // Hide poster to have black background
+ this.player.removeClass('vjs-playing-audio-only-content')
+ this.player.posterImage.hide()
+ } else {
+ // Audio-only: show poster, do not auto-hide controls
+ this.player.addClass('vjs-playing-audio-only-content')
+ this.player.posterImage.show()
+ }
+
const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId)
const options = {
forcePlay: false,
delay,
seek: currentTime + (delay / 1000)
}
+
this.updateVideoFile(newVideoFile, options)
}
this.player.posterImage.show()
this.player.removeClass('vjs-has-autoplay')
this.player.removeClass('vjs-has-big-play-button-clicked')
+ this.player.removeClass('vjs-playing-audio-only-content')
return done()
})
allow_audio_files: true
threads: 1
resolutions: # Only created if the original video has a higher resolution, uses more storage!
+ 0p: false # audio-only (creates mp4 without video stream)
240p: false
360p: false
480p: false
allow_audio_files: true
threads: 1
resolutions: # Only created if the original video has a higher resolution, uses more storage!
+ 0p: false # audio-only (creates mp4 without video stream)
240p: false
360p: false
480p: false
allow_audio_files: false
threads: 2
resolutions:
+ 0p: true
240p: true
360p: true
480p: true
allowAudioFiles: CONFIG.TRANSCODING.ALLOW_AUDIO_FILES,
threads: CONFIG.TRANSCODING.THREADS,
resolutions: {
+ '0p': CONFIG.TRANSCODING.RESOLUTIONS[ '0p' ],
'240p': CONFIG.TRANSCODING.RESOLUTIONS[ '240p' ],
'360p': CONFIG.TRANSCODING.RESOLUTIONS[ '360p' ],
'480p': CONFIG.TRANSCODING.RESOLUTIONS[ '480p' ],
function keyConverter (k: string) {
// Transcoding resolutions exception
if (/^\d{3,4}p$/.exec(k)) return k
+ if (/^0p$/.exec(k)) return k
return snakeCase(k)
}
// Put in the order we want to proceed jobs
const resolutions = [
+ VideoResolution.H_NOVIDEO,
VideoResolution.H_480P,
VideoResolution.H_360P,
VideoResolution.H_720P,
async function getVideoFileSize (path: string) {
const videoStream = await getVideoStreamFromFile(path)
- return {
- width: videoStream.width,
- height: videoStream.height
- }
+ return videoStream == null
+ ? {
+ width: 0,
+ height: 0
+ }
+ : {
+ width: videoStream.width,
+ height: videoStream.height
+ }
}
async function getVideoFileResolution (path: string) {
async function getVideoFileFPS (path: string) {
const videoStream = await getVideoStreamFromFile(path)
+ if (videoStream == null) {
+ return 0
+ }
+
for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
const valuesText: string = videoStream[key]
if (!valuesText) continue
}
}
-type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio'
+type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio' | 'split-audio'
interface BaseTranscodeOptions {
type: TranscodeOptionsType
audioPath: string
}
-type TranscodeOptions = HLSTranscodeOptions | VideoTranscodeOptions | MergeAudioTranscodeOptions | QuickTranscodeOptions
+interface SplitAudioTranscodeOptions extends BaseTranscodeOptions {
+ type: 'split-audio'
+}
+
+type TranscodeOptions = HLSTranscodeOptions | VideoTranscodeOptions | MergeAudioTranscodeOptions | SplitAudioTranscodeOptions | QuickTranscodeOptions
function transcode (options: TranscodeOptions) {
return new Promise<void>(async (res, rej) => {
command = await buildHLSCommand(command, options)
} else if (options.type === 'merge-audio') {
command = await buildAudioMergeCommand(command, options)
+ } else if (options.type === 'split-audio') {
+ command = await buildAudioSplitCommand(command, options)
} else {
command = await buildx264Command(command, options)
}
const resolution = await getVideoFileResolution(path)
// check video params
+ if (videoStream == null) return false
if (videoStream[ 'codec_name' ] !== 'h264') return false
if (videoStream[ 'pix_fmt' ] !== 'yuv420p') return false
if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false
return command
}
+async function buildAudioSplitCommand (command: ffmpeg.FfmpegCommand, options: SplitAudioTranscodeOptions) {
+ command = await presetAudioSplit(command)
+
+ return command
+}
+
async function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
command = await presetCopy(command)
if (err) return rej(err)
const videoStream = metadata.streams.find(s => s.codec_type === 'video')
- if (!videoStream) return rej(new Error('Cannot find video stream of ' + path))
+ //if (!videoStream) return rej(new Error('Cannot find video stream of ' + path))
return res(videoStream)
})
.videoCodec('copy')
.audioCodec('copy')
}
+
+
+async function presetAudioSplit (command: ffmpeg.FfmpegCommand): Promise<ffmpeg.FfmpegCommand> {
+ return command
+ .format('mp4')
+ .audioCodec('copy')
+ .noVideo()
+}
get ALLOW_AUDIO_FILES () { return config.get<boolean>('transcoding.allow_audio_files') },
get THREADS () { return config.get<number>('transcoding.threads') },
RESOLUTIONS: {
+ get '0p' () { return config.get<boolean>('transcoding.resolutions.0p') },
get '240p' () { return config.get<boolean>('transcoding.resolutions.240p') },
get '360p' () { return config.get<boolean>('transcoding.resolutions.360p') },
get '480p' () { return config.get<boolean>('transcoding.resolutions.480p') },
const videoOutputPath = getVideoFilePath(video, newVideoFile)
const videoTranscodedPath = join(transcodeDirectory, getVideoFilename(video, newVideoFile))
+ const transcodeOptions = resolution === VideoResolution.H_NOVIDEO
+ ? {
+ type: 'split-audio' as 'split-audio',
+ inputPath: videoInputPath,
+ outputPath: videoTranscodedPath,
+ resolution,
+ }
+ : {
+ type: 'video' as 'video',
+ inputPath: videoInputPath,
+ outputPath: videoTranscodedPath,
+ resolution,
+ isPortraitMode: isPortrait
+ }
+
+ await transcode(transcodeOptions)
+
+ return onVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath)
+}
+
+/**
+ * Extract audio into a separate audio-only mp4.
+ */
+async function splitAudioFile (video: MVideoWithFile) {
+ const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
+ const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
+ const extname = '.mp4'
+ const resolution = VideoResolution.H_NOVIDEO
+
+ // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
+ const videoInputPath = join(videosDirectory, video.getVideoFilename(video.getOriginalFile()))
+
+ const newVideoFile = new VideoFileModel({
+ resolution,
+ extname,
+ size: 0,
+ videoId: video.id
+ })
+ const videoOutputPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename(newVideoFile))
+ const videoTranscodedPath = join(transcodeDirectory, video.getVideoFilename(newVideoFile))
+
const transcodeOptions = {
- type: 'video' as 'video',
+ type: 'split-audio' as 'split-audio',
inputPath: videoInputPath,
outputPath: videoTranscodedPath,
- resolution,
- isPortraitMode: isPortrait
+ resolution
}
await transcode(transcodeOptions)
body('transcoding.enabled').isBoolean().withMessage('Should have a valid transcoding enabled boolean'),
body('transcoding.allowAdditionalExtensions').isBoolean().withMessage('Should have a valid additional extensions boolean'),
body('transcoding.threads').isInt().withMessage('Should have a valid transcoding threads number'),
+ body('transcoding.resolutions.0p').isBoolean().withMessage('Should have a valid transcoding 0p resolution enabled boolean'),
body('transcoding.resolutions.240p').isBoolean().withMessage('Should have a valid transcoding 240p resolution enabled boolean'),
body('transcoding.resolutions.360p').isBoolean().withMessage('Should have a valid transcoding 360p resolution enabled boolean'),
body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'),
allowAudioFiles: true,
threads: 1,
resolutions: {
+ '0p': false,
'240p': false,
'360p': true,
'480p': true,
allowAudioFiles: true,
threads: 1,
resolutions: {
+ '0p': false,
'240p': false,
'360p': true,
'480p': true,
allowAudioFiles: true,
threads: 1,
resolutions: {
+ '0p': false,
'240p': false,
'360p': true,
'480p': true,
threads: number
resolutions: {
+ '0p': boolean
'240p': boolean
'360p': boolean
'480p': boolean
import { VideoTranscodingFPS } from './video-transcoding-fps.model'
export enum VideoResolution {
+ H_NOVIDEO = 0,
H_240P = 240,
H_360P = 360,
H_480P = 480,
*/
function getBaseBitrate (resolution: VideoResolution) {
switch (resolution) {
+ case VideoResolution.H_NOVIDEO:
+ // audio-only
+ return 64 * 1000
+
case VideoResolution.H_240P:
// quality according to Google Live Encoder: 300 - 700 Kbps
// Quality according to YouTube Video Info: 186 Kbps