diff options
-rw-r--r-- | client/src/assets/player/videojs-components/resolution-menu-button.ts | 6 | ||||
-rw-r--r-- | client/src/assets/player/webtorrent/webtorrent-plugin.ts | 12 | ||||
-rw-r--r-- | config/test.yaml | 2 | ||||
-rw-r--r-- | server/controllers/api/config.ts | 2 | ||||
-rw-r--r-- | server/helpers/ffmpeg-utils.ts | 54 | ||||
-rw-r--r-- | server/lib/video-transcoding.ts | 55 | ||||
-rw-r--r-- | server/tests/api/videos/audio-only.ts | 108 | ||||
-rw-r--r-- | server/tests/api/videos/index.ts | 1 |
8 files changed, 156 insertions, 84 deletions
diff --git a/client/src/assets/player/videojs-components/resolution-menu-button.ts b/client/src/assets/player/videojs-components/resolution-menu-button.ts index 445b14b2b..86be03af7 100644 --- a/client/src/assets/player/videojs-components/resolution-menu-button.ts +++ b/client/src/assets/player/videojs-components/resolution-menu-button.ts | |||
@@ -75,11 +75,15 @@ class ResolutionMenuButton extends MenuButton { | |||
75 | // Skip auto resolution, we'll add it ourselves | 75 | // Skip auto resolution, we'll add it ourselves |
76 | if (d.id === -1) continue | 76 | if (d.id === -1) continue |
77 | 77 | ||
78 | const label = d.id === 0 | ||
79 | ? this.player.localize('Audio-only') | ||
80 | : d.label | ||
81 | |||
78 | this.menu.addChild(new ResolutionMenuItem( | 82 | this.menu.addChild(new ResolutionMenuItem( |
79 | this.player_, | 83 | this.player_, |
80 | { | 84 | { |
81 | id: d.id, | 85 | id: d.id, |
82 | label: d.id == 0 ? this.player .localize('Audio-only') : d.label, | 86 | label, |
83 | selected: d.selected, | 87 | selected: d.selected, |
84 | callback: data.qualitySwitchCallback | 88 | callback: data.qualitySwitchCallback |
85 | }) | 89 | }) |
diff --git a/client/src/assets/player/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/webtorrent/webtorrent-plugin.ts index 007fc58cc..8b5690cea 100644 --- a/client/src/assets/player/webtorrent/webtorrent-plugin.ts +++ b/client/src/assets/player/webtorrent/webtorrent-plugin.ts | |||
@@ -186,15 +186,15 @@ class WebTorrentPlugin extends Plugin { | |||
186 | this.player.bigPlayButton.hide() | 186 | this.player.bigPlayButton.hide() |
187 | } | 187 | } |
188 | 188 | ||
189 | // Audio-only (resolutionId == 0) gets special treatment | 189 | // Audio-only (resolutionId === 0) gets special treatment |
190 | if (resolutionId > 0) { | 190 | if (resolutionId === 0) { |
191 | // Hide poster to have black background | ||
192 | this.player.removeClass('vjs-playing-audio-only-content') | ||
193 | this.player.posterImage.hide() | ||
194 | } else { | ||
195 | // Audio-only: show poster, do not auto-hide controls | 191 | // Audio-only: show poster, do not auto-hide controls |
196 | this.player.addClass('vjs-playing-audio-only-content') | 192 | this.player.addClass('vjs-playing-audio-only-content') |
197 | this.player.posterImage.show() | 193 | this.player.posterImage.show() |
194 | } else { | ||
195 | // Hide poster to have black background | ||
196 | this.player.removeClass('vjs-playing-audio-only-content') | ||
197 | this.player.posterImage.hide() | ||
198 | } | 198 | } |
199 | 199 | ||
200 | const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId) | 200 | const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId) |
diff --git a/config/test.yaml b/config/test.yaml index eedd28537..3ab391504 100644 --- a/config/test.yaml +++ b/config/test.yaml | |||
@@ -72,7 +72,7 @@ transcoding: | |||
72 | allow_audio_files: false | 72 | allow_audio_files: false |
73 | threads: 2 | 73 | threads: 2 |
74 | resolutions: | 74 | resolutions: |
75 | 0p: true | 75 | 0p: false |
76 | 240p: true | 76 | 240p: true |
77 | 360p: true | 77 | 360p: true |
78 | 480p: true | 78 | 480p: true |
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index 8a00f9835..c593fa302 100644 --- a/server/controllers/api/config.ts +++ b/server/controllers/api/config.ts | |||
@@ -357,7 +357,7 @@ function convertCustomConfigBody (body: CustomConfig) { | |||
357 | function keyConverter (k: string) { | 357 | function keyConverter (k: string) { |
358 | // Transcoding resolutions exception | 358 | // Transcoding resolutions exception |
359 | if (/^\d{3,4}p$/.exec(k)) return k | 359 | if (/^\d{3,4}p$/.exec(k)) return k |
360 | if (/^0p$/.exec(k)) return k | 360 | if (k === '0p') return k |
361 | 361 | ||
362 | return snakeCase(k) | 362 | return snakeCase(k) |
363 | } | 363 | } |
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index 2d9ce2bfa..ff80991b2 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts | |||
@@ -35,15 +35,9 @@ function computeResolutionsToTranscode (videoFileHeight: number) { | |||
35 | async function getVideoFileSize (path: string) { | 35 | async function getVideoFileSize (path: string) { |
36 | const videoStream = await getVideoStreamFromFile(path) | 36 | const videoStream = await getVideoStreamFromFile(path) |
37 | 37 | ||
38 | return videoStream == null | 38 | return videoStream === null |
39 | ? { | 39 | ? { width: 0, height: 0 } |
40 | width: 0, | 40 | : { width: videoStream.width, height: videoStream.height } |
41 | height: 0 | ||
42 | } | ||
43 | : { | ||
44 | width: videoStream.width, | ||
45 | height: videoStream.height | ||
46 | } | ||
47 | } | 41 | } |
48 | 42 | ||
49 | async function getVideoFileResolution (path: string) { | 43 | async function getVideoFileResolution (path: string) { |
@@ -57,13 +51,10 @@ async function getVideoFileResolution (path: string) { | |||
57 | 51 | ||
58 | async function getVideoFileFPS (path: string) { | 52 | async function getVideoFileFPS (path: string) { |
59 | const videoStream = await getVideoStreamFromFile(path) | 53 | const videoStream = await getVideoStreamFromFile(path) |
60 | 54 | if (videoStream === null) return 0 | |
61 | if (videoStream == null) { | ||
62 | return 0 | ||
63 | } | ||
64 | 55 | ||
65 | for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) { | 56 | for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) { |
66 | const valuesText: string = videoStream[key] | 57 | const valuesText: string = videoStream[ key ] |
67 | if (!valuesText) continue | 58 | if (!valuesText) continue |
68 | 59 | ||
69 | const [ frames, seconds ] = valuesText.split('/') | 60 | const [ frames, seconds ] = valuesText.split('/') |
@@ -128,7 +119,7 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima | |||
128 | } | 119 | } |
129 | } | 120 | } |
130 | 121 | ||
131 | type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio' | 'split-audio' | 122 | type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' |
132 | 123 | ||
133 | interface BaseTranscodeOptions { | 124 | interface BaseTranscodeOptions { |
134 | type: TranscodeOptionsType | 125 | type: TranscodeOptionsType |
@@ -159,11 +150,15 @@ interface MergeAudioTranscodeOptions extends BaseTranscodeOptions { | |||
159 | audioPath: string | 150 | audioPath: string |
160 | } | 151 | } |
161 | 152 | ||
162 | interface SplitAudioTranscodeOptions extends BaseTranscodeOptions { | 153 | interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions { |
163 | type: 'split-audio' | 154 | type: 'only-audio' |
164 | } | 155 | } |
165 | 156 | ||
166 | type TranscodeOptions = HLSTranscodeOptions | VideoTranscodeOptions | MergeAudioTranscodeOptions | SplitAudioTranscodeOptions | QuickTranscodeOptions | 157 | type TranscodeOptions = HLSTranscodeOptions |
158 | | VideoTranscodeOptions | ||
159 | | MergeAudioTranscodeOptions | ||
160 | | OnlyAudioTranscodeOptions | ||
161 | | QuickTranscodeOptions | ||
167 | 162 | ||
168 | function transcode (options: TranscodeOptions) { | 163 | function transcode (options: TranscodeOptions) { |
169 | return new Promise<void>(async (res, rej) => { | 164 | return new Promise<void>(async (res, rej) => { |
@@ -177,8 +172,8 @@ function transcode (options: TranscodeOptions) { | |||
177 | command = await buildHLSCommand(command, options) | 172 | command = await buildHLSCommand(command, options) |
178 | } else if (options.type === 'merge-audio') { | 173 | } else if (options.type === 'merge-audio') { |
179 | command = await buildAudioMergeCommand(command, options) | 174 | command = await buildAudioMergeCommand(command, options) |
180 | } else if (options.type === 'split-audio') { | 175 | } else if (options.type === 'only-audio') { |
181 | command = await buildAudioSplitCommand(command, options) | 176 | command = await buildOnlyAudioCommand(command, options) |
182 | } else { | 177 | } else { |
183 | command = await buildx264Command(command, options) | 178 | command = await buildx264Command(command, options) |
184 | } | 179 | } |
@@ -220,7 +215,7 @@ async function canDoQuickTranscode (path: string): Promise<boolean> { | |||
220 | if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false | 215 | if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false |
221 | if (bitRate > getMaxBitrate(resolution.videoFileResolution, fps, VIDEO_TRANSCODING_FPS)) return false | 216 | if (bitRate > getMaxBitrate(resolution.videoFileResolution, fps, VIDEO_TRANSCODING_FPS)) return false |
222 | 217 | ||
223 | // check audio params (if audio stream exists) | 218 | // check audio params (if audio stream exists) |
224 | if (parsedAudio.audioStream) { | 219 | if (parsedAudio.audioStream) { |
225 | if (parsedAudio.audioStream[ 'codec_name' ] !== 'aac') return false | 220 | if (parsedAudio.audioStream[ 'codec_name' ] !== 'aac') return false |
226 | 221 | ||
@@ -293,8 +288,8 @@ async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: M | |||
293 | return command | 288 | return command |
294 | } | 289 | } |
295 | 290 | ||
296 | async function buildAudioSplitCommand (command: ffmpeg.FfmpegCommand, options: SplitAudioTranscodeOptions) { | 291 | async function buildOnlyAudioCommand (command: ffmpeg.FfmpegCommand, options: OnlyAudioTranscodeOptions) { |
297 | command = await presetAudioSplit(command) | 292 | command = await presetOnlyAudio(command) |
298 | 293 | ||
299 | return command | 294 | return command |
300 | } | 295 | } |
@@ -350,9 +345,7 @@ function getVideoStreamFromFile (path: string) { | |||
350 | if (err) return rej(err) | 345 | if (err) return rej(err) |
351 | 346 | ||
352 | const videoStream = metadata.streams.find(s => s.codec_type === 'video') | 347 | const videoStream = metadata.streams.find(s => s.codec_type === 'video') |
353 | //if (!videoStream) return rej(new Error('Cannot find video stream of ' + path)) | 348 | return res(videoStream || null) |
354 | |||
355 | return res(videoStream) | ||
356 | }) | 349 | }) |
357 | }) | 350 | }) |
358 | } | 351 | } |
@@ -384,7 +377,7 @@ async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, input: string, | |||
384 | * A toolbox to play with audio | 377 | * A toolbox to play with audio |
385 | */ | 378 | */ |
386 | namespace audio { | 379 | namespace audio { |
387 | export const get = (option: string) => { | 380 | export const get = (videoPath: string) => { |
388 | // without position, ffprobe considers the last input only | 381 | // without position, ffprobe considers the last input only |
389 | // we make it consider the first input only | 382 | // we make it consider the first input only |
390 | // if you pass a file path to pos, then ffprobe acts on that file directly | 383 | // if you pass a file path to pos, then ffprobe acts on that file directly |
@@ -394,7 +387,7 @@ namespace audio { | |||
394 | if (err) return rej(err) | 387 | if (err) return rej(err) |
395 | 388 | ||
396 | if ('streams' in data) { | 389 | if ('streams' in data) { |
397 | const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio') | 390 | const audioStream = data.streams.find(stream => stream[ 'codec_type' ] === 'audio') |
398 | if (audioStream) { | 391 | if (audioStream) { |
399 | return res({ | 392 | return res({ |
400 | absolutePath: data.format.filename, | 393 | absolutePath: data.format.filename, |
@@ -406,7 +399,7 @@ namespace audio { | |||
406 | return res({ absolutePath: data.format.filename }) | 399 | return res({ absolutePath: data.format.filename }) |
407 | } | 400 | } |
408 | 401 | ||
409 | return ffmpeg.ffprobe(option, parseFfprobe) | 402 | return ffmpeg.ffprobe(videoPath, parseFfprobe) |
410 | }) | 403 | }) |
411 | } | 404 | } |
412 | 405 | ||
@@ -506,8 +499,7 @@ async function presetCopy (command: ffmpeg.FfmpegCommand): Promise<ffmpeg.Ffmpeg | |||
506 | .audioCodec('copy') | 499 | .audioCodec('copy') |
507 | } | 500 | } |
508 | 501 | ||
509 | 502 | async function presetOnlyAudio (command: ffmpeg.FfmpegCommand): Promise<ffmpeg.FfmpegCommand> { | |
510 | async function presetAudioSplit (command: ffmpeg.FfmpegCommand): Promise<ffmpeg.FfmpegCommand> { | ||
511 | return command | 503 | return command |
512 | .format('mp4') | 504 | .format('mp4') |
513 | .audioCodec('copy') | 505 | .audioCodec('copy') |
diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts index 9dd54837f..ab5200936 100644 --- a/server/lib/video-transcoding.ts +++ b/server/lib/video-transcoding.ts | |||
@@ -83,51 +83,18 @@ async function transcodeNewResolution (video: MVideoWithFile, resolution: VideoR | |||
83 | 83 | ||
84 | const transcodeOptions = resolution === VideoResolution.H_NOVIDEO | 84 | const transcodeOptions = resolution === VideoResolution.H_NOVIDEO |
85 | ? { | 85 | ? { |
86 | type: 'split-audio' as 'split-audio', | 86 | type: 'only-audio' as 'only-audio', |
87 | inputPath: videoInputPath, | 87 | inputPath: videoInputPath, |
88 | outputPath: videoTranscodedPath, | 88 | outputPath: videoTranscodedPath, |
89 | resolution, | 89 | resolution |
90 | } | 90 | } |
91 | : { | 91 | : { |
92 | type: 'video' as 'video', | 92 | type: 'video' as 'video', |
93 | inputPath: videoInputPath, | 93 | inputPath: videoInputPath, |
94 | outputPath: videoTranscodedPath, | 94 | outputPath: videoTranscodedPath, |
95 | resolution, | 95 | resolution, |
96 | isPortraitMode: isPortrait | 96 | isPortraitMode: isPortrait |
97 | } | 97 | } |
98 | |||
99 | await transcode(transcodeOptions) | ||
100 | |||
101 | return onVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath) | ||
102 | } | ||
103 | |||
104 | /** | ||
105 | * Extract audio into a separate audio-only mp4. | ||
106 | */ | ||
107 | async function splitAudioFile (video: MVideoWithFile) { | ||
108 | const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR | ||
109 | const transcodeDirectory = CONFIG.STORAGE.TMP_DIR | ||
110 | const extname = '.mp4' | ||
111 | const resolution = VideoResolution.H_NOVIDEO | ||
112 | |||
113 | // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed | ||
114 | const videoInputPath = join(videosDirectory, video.getVideoFilename(video.getOriginalFile())) | ||
115 | |||
116 | const newVideoFile = new VideoFileModel({ | ||
117 | resolution, | ||
118 | extname, | ||
119 | size: 0, | ||
120 | videoId: video.id | ||
121 | }) | ||
122 | const videoOutputPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename(newVideoFile)) | ||
123 | const videoTranscodedPath = join(transcodeDirectory, video.getVideoFilename(newVideoFile)) | ||
124 | |||
125 | const transcodeOptions = { | ||
126 | type: 'split-audio' as 'split-audio', | ||
127 | inputPath: videoInputPath, | ||
128 | outputPath: videoTranscodedPath, | ||
129 | resolution | ||
130 | } | ||
131 | 98 | ||
132 | await transcode(transcodeOptions) | 99 | await transcode(transcodeOptions) |
133 | 100 | ||
diff --git a/server/tests/api/videos/audio-only.ts b/server/tests/api/videos/audio-only.ts new file mode 100644 index 000000000..1ccae4351 --- /dev/null +++ b/server/tests/api/videos/audio-only.ts | |||
@@ -0,0 +1,108 @@ | |||
1 | /* tslint:disable:no-unused-expression */ | ||
2 | |||
3 | import * as chai from 'chai' | ||
4 | import 'mocha' | ||
5 | import { | ||
6 | checkDirectoryIsEmpty, | ||
7 | checkSegmentHash, | ||
8 | checkTmpIsEmpty, | ||
9 | cleanupTests, | ||
10 | doubleFollow, | ||
11 | flushAndRunMultipleServers, | ||
12 | getPlaylist, | ||
13 | getVideo, makeGetRequest, makeRawRequest, | ||
14 | removeVideo, root, | ||
15 | ServerInfo, | ||
16 | setAccessTokensToServers, updateCustomSubConfig, | ||
17 | updateVideo, | ||
18 | uploadVideo, | ||
19 | waitJobs, webtorrentAdd | ||
20 | } from '../../../../shared/extra-utils' | ||
21 | import { VideoDetails } from '../../../../shared/models/videos' | ||
22 | import { VideoStreamingPlaylistType } from '../../../../shared/models/videos/video-streaming-playlist.type' | ||
23 | import { join } from 'path' | ||
24 | import { DEFAULT_AUDIO_RESOLUTION } from '../../../initializers/constants' | ||
25 | import { getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution, audio, getVideoFileSize } from '@server/helpers/ffmpeg-utils' | ||
26 | |||
27 | const expect = chai.expect | ||
28 | |||
29 | describe('Test audio only video transcoding', function () { | ||
30 | let servers: ServerInfo[] = [] | ||
31 | let videoUUID: string | ||
32 | |||
33 | before(async function () { | ||
34 | this.timeout(120000) | ||
35 | |||
36 | const configOverride = { | ||
37 | transcoding: { | ||
38 | enabled: true, | ||
39 | resolutions: { | ||
40 | '0p': true, | ||
41 | '240p': true, | ||
42 | '360p': false, | ||
43 | '480p': false, | ||
44 | '720p': false, | ||
45 | '1080p': false, | ||
46 | '2160p': false | ||
47 | }, | ||
48 | hls: { | ||
49 | enabled: true | ||
50 | }, | ||
51 | webtorrent: { | ||
52 | enabled: true | ||
53 | } | ||
54 | } | ||
55 | } | ||
56 | servers = await flushAndRunMultipleServers(2, configOverride) | ||
57 | |||
58 | // Get the access tokens | ||
59 | await setAccessTokensToServers(servers) | ||
60 | |||
61 | // Server 1 and server 2 follow each other | ||
62 | await doubleFollow(servers[0], servers[1]) | ||
63 | }) | ||
64 | |||
65 | it('Should upload a video and transcode it', async function () { | ||
66 | this.timeout(120000) | ||
67 | |||
68 | const resUpload = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'audio only'}) | ||
69 | videoUUID = resUpload.body.video.uuid | ||
70 | |||
71 | await waitJobs(servers) | ||
72 | |||
73 | for (const server of servers) { | ||
74 | const res = await getVideo(server.url, videoUUID) | ||
75 | const video: VideoDetails = res.body | ||
76 | |||
77 | expect(video.streamingPlaylists).to.have.lengthOf(1) | ||
78 | |||
79 | for (const files of [ video.files, video.streamingPlaylists[0].files ]) { | ||
80 | expect(files).to.have.lengthOf(3) | ||
81 | expect(files[0].resolution.id).to.equal(720) | ||
82 | expect(files[1].resolution.id).to.equal(240) | ||
83 | expect(files[2].resolution.id).to.equal(0) | ||
84 | } | ||
85 | } | ||
86 | }) | ||
87 | |||
88 | it('0p transcoded video should not have video', async function () { | ||
89 | const paths = [ | ||
90 | join(root(), 'test' + servers[ 0 ].internalServerNumber, 'videos', videoUUID + '-0.mp4'), | ||
91 | join(root(), 'test' + servers[ 0 ].internalServerNumber, 'streaming-playlists', 'hls', videoUUID, videoUUID + '-0-fragmented.mp4') | ||
92 | ] | ||
93 | |||
94 | for (const path of paths) { | ||
95 | const { audioStream } = await audio.get(path) | ||
96 | expect(audioStream[ 'codec_name' ]).to.be.equal('aac') | ||
97 | expect(audioStream[ 'bit_rate' ]).to.be.at.most(384 * 8000) | ||
98 | |||
99 | const size = await getVideoFileSize(path) | ||
100 | expect(size.height).to.equal(0) | ||
101 | expect(size.width).to.equal(0) | ||
102 | } | ||
103 | }) | ||
104 | |||
105 | after(async function () { | ||
106 | await cleanupTests(servers) | ||
107 | }) | ||
108 | }) | ||
diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts index 72e6061bb..4d35d3b7c 100644 --- a/server/tests/api/videos/index.ts +++ b/server/tests/api/videos/index.ts | |||
@@ -1,3 +1,4 @@ | |||
1 | import './audio-only' | ||
1 | import './multiple-servers' | 2 | import './multiple-servers' |
2 | import './services' | 3 | import './services' |
3 | import './single-server' | 4 | import './single-server' |