Browse Source

Add option to not transcode original resolution

Chocobozzz 1 year ago
parent
commit
84cae54e7a

+ 3 - 1
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts

@@ -175,6 +175,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
         profile: null,
         concurrency: CONCURRENCY_VALIDATOR,
         resolutions: {},
+        alwaysTranscodeOriginalResolution: null,
         hls: {
           enabled: null
         },
@@ -197,7 +198,8 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
           enabled: null,
           threads: TRANSCODING_THREADS_VALIDATOR,
           profile: null,
-          resolutions: {}
+          resolutions: {},
+          alwaysTranscodeOriginalResolution: null
         }
       },
       videoStudio: {

+ 12 - 3
client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html

@@ -41,7 +41,6 @@
                   <ng-container ngProjectAs="description" i18n>
                     Small latency disables P2P and high latency can increase P2P ratio
                   </ng-container>
-
                 </my-peertube-checkbox>
               </div>
 
@@ -115,8 +114,8 @@
             <label i18n for="liveTranscodingThreads">Live resolutions to generate</label>
 
             <div class="ms-2 mt-2 d-flex flex-column">
-              <ng-container formGroupName="resolutions">
 
+              <ng-container formGroupName="resolutions">
                 <div class="form-group" *ngFor="let resolution of liveResolutions">
                   <my-peertube-checkbox
                     [inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id"
@@ -127,8 +126,18 @@
                     </ng-template>
                   </my-peertube-checkbox>
                 </div>
-
               </ng-container>
+
+              <div class="form-group">
+                <my-peertube-checkbox
+                  inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
+                  i18n-labelText labelText="Also transcode original resolution"
+                >
+                  <ng-container i18n ngProjectAs="description">
+                    Even if it's above your maximum enabled resolution
+                  </ng-container>
+                </my-peertube-checkbox>
+              </div>
             </div>
           </div>
 

+ 7 - 1
client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html

@@ -111,7 +111,13 @@
                   <label i18n>Resolutions to generate per enabled format</label>
 
                   <div class="ms-2 d-flex flex-column">
-                    <span class="mb-3 small muted" i18n>
+                    <my-peertube-checkbox
+                      inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
+                      i18n-labelText labelText="Always transcode original resolution"
+                    >
+                    </my-peertube-checkbox>
+
+                    <span class="mt-3 mb-2 small muted" i18n>
                       The original file resolution will be the default target if no option is selected.
                     </span>
 

+ 6 - 0
config/default.yaml

@@ -403,6 +403,9 @@ transcoding:
     1440p: false
     2160p: false
 
+  # Transcode and keep original resolution, even if it's above your maximum enabled resolution
+  always_transcode_original_resolution: true
+
   # Generate videos in a WebTorrent format (what we do since the first PeerTube release)
   # If you also enabled the hls format, it will multiply videos storage by 2
   # If disabled, breaks federation with PeerTube instances < 2.1
@@ -496,6 +499,9 @@ live:
       1440p: false
       2160p: false
 
+    # Also transcode original resolution, even if it's above your maximum enabled resolution
+    always_transcode_original_resolution: true
+
 video_studio:
   # Enable video edition by users (cut, add intro/outro, add watermark etc)
   # If enabled, users can create transcoding tasks as they wish

+ 6 - 0
config/production.yaml.example

@@ -413,6 +413,9 @@ transcoding:
     1440p: false
     2160p: false
 
+  # Transcode and keep original resolution, even if it's above your maximum enabled resolution
+  always_transcode_original_resolution: true
+
   # Generate videos in a WebTorrent format (what we do since the first PeerTube release)
   # If you also enabled the hls format, it will multiply videos storage by 2
   # If disabled, breaks federation with PeerTube instances < 2.1
@@ -506,6 +509,9 @@ live:
       1440p: false
       2160p: false
 
+    # Also transcode original resolution, even if it's above your maximum enabled resolution
+    always_transcode_original_resolution: true
+
 video_studio:
   # Enable video edition by users (cut, add intro/outro, add watermark etc)
   # If enabled, users can create transcoding tasks as they wish

+ 2 - 4
scripts/create-transcoding-job.ts

@@ -1,6 +1,6 @@
 import { program } from 'commander'
 import { isUUIDValid, toCompleteUUID } from '@server/helpers/custom-validators/misc'
-import { computeLowerResolutionsToTranscode } from '@server/helpers/ffmpeg'
+import { computeResolutionsToTranscode } from '@server/helpers/ffmpeg'
 import { CONFIG } from '@server/initializers/config'
 import { addTranscodingJob } from '@server/lib/video'
 import { VideoState, VideoTranscodingPayload } from '@shared/models'
@@ -53,7 +53,7 @@ async function run () {
   if (options.generateHls || CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false) {
     const resolutionsEnabled = options.resolution
       ? [ parseInt(options.resolution) ]
-      : computeLowerResolutionsToTranscode(maxResolution, 'vod').concat([ maxResolution ])
+      : computeResolutionsToTranscode({ inputResolution: maxResolution, type: 'vod', includeInputResolution: true })
 
     for (const resolution of resolutionsEnabled) {
       dataInput.push({
@@ -61,8 +61,6 @@ async function run () {
         videoUUID: video.uuid,
         resolution,
 
-        // FIXME: check the file has audio and is not in portrait mode
-        isPortraitMode: false,
         hasAudio: true,
 
         copyCodecs: false,

+ 1 - 2
scripts/print-transcode-command.ts

@@ -31,8 +31,7 @@ async function run (path: string, cmd: any) {
     availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
     profile: 'default',
 
-    resolution: +cmd.resolution,
-    isPortraitMode: false
+    resolution: +cmd.resolution
   } as TranscodeVODOptions
 
   let command = ffmpeg(options.inputPath)

+ 3 - 1
server/controllers/api/config.ts

@@ -227,6 +227,7 @@ function customConfig (): CustomConfig {
         '1440p': CONFIG.TRANSCODING.RESOLUTIONS['1440p'],
         '2160p': CONFIG.TRANSCODING.RESOLUTIONS['2160p']
       },
+      alwaysTranscodeOriginalResolution: CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION,
       webtorrent: {
         enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
       },
@@ -256,7 +257,8 @@ function customConfig (): CustomConfig {
           '1080p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['1080p'],
           '1440p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['1440p'],
           '2160p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['2160p']
-        }
+        },
+        alwaysTranscodeOriginalResolution: CONFIG.LIVE.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION
       }
     },
     videoStudio: {

+ 4 - 6
server/controllers/api/videos/transcoding.ts

@@ -1,5 +1,5 @@
 import express from 'express'
-import { computeLowerResolutionsToTranscode } from '@server/helpers/ffmpeg'
+import { computeResolutionsToTranscode } from '@server/helpers/ffmpeg'
 import { logger, loggerTagsFactory } from '@server/helpers/logger'
 import { addTranscodingJob } from '@server/lib/video'
 import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@shared/models'
@@ -30,9 +30,9 @@ async function createTranscoding (req: express.Request, res: express.Response) {
 
   const body: VideoTranscodingCreate = req.body
 
-  const { resolution: maxResolution, isPortraitMode, audioStream } = await video.probeMaxQualityFile()
+  const { resolution: maxResolution, audioStream } = await video.probeMaxQualityFile()
   const resolutions = await Hooks.wrapObject(
-    computeLowerResolutionsToTranscode(maxResolution, 'vod').concat([ maxResolution ]),
+    computeResolutionsToTranscode({ inputResolution: maxResolution, type: 'vod', includeInputResolution: true }),
     'filter:transcoding.manual.lower-resolutions-to-transcode.result',
     body
   )
@@ -50,7 +50,6 @@ async function createTranscoding (req: express.Request, res: express.Response) {
         type: 'new-resolution-to-hls',
         videoUUID: video.uuid,
         resolution,
-        isPortraitMode,
         hasAudio: !!audioStream,
         copyCodecs: false,
         isNewVideo: false,
@@ -64,8 +63,7 @@ async function createTranscoding (req: express.Request, res: express.Response) {
         isNewVideo: false,
         resolution,
         hasAudio: !!audioStream,
-        createHLSIfNeeded: false,
-        isPortraitMode
+        createHLSIfNeeded: false
       })
     }
   }

+ 7 - 5
server/helpers/ffmpeg/ffmpeg-vod.ts

@@ -7,7 +7,7 @@ import { AvailableEncoders, VideoResolution } from '@shared/models'
 import { logger, loggerTagsFactory } from '../logger'
 import { getFFmpeg, runCommand } from './ffmpeg-commons'
 import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets'
-import { computeFPS, getVideoStreamFPS } from './ffprobe-utils'
+import { computeFPS, ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS } from './ffprobe-utils'
 import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
 
 const lTags = loggerTagsFactory('ffmpeg')
@@ -27,8 +27,6 @@ interface BaseTranscodeVODOptions {
 
   resolution: number
 
-  isPortraitMode?: boolean
-
   job?: Job
 }
 
@@ -115,13 +113,17 @@ export {
 // ---------------------------------------------------------------------------
 
 async function buildVODCommand (command: FfmpegCommand, options: TranscodeVODOptions) {
-  let fps = await getVideoStreamFPS(options.inputPath)
+  const probe = await ffprobePromise(options.inputPath)
+
+  let fps = await getVideoStreamFPS(options.inputPath, probe)
   fps = computeFPS(fps, options.resolution)
 
   let scaleFilterValue: string
 
   if (options.resolution !== undefined) {
-    scaleFilterValue = options.isPortraitMode === true
+    const videoStreamInfo = await getVideoStreamDimensionsInfo(options.inputPath, probe)
+
+    scaleFilterValue = videoStreamInfo?.isPortraitMode === true
       ? `w=${options.resolution}:h=-2`
       : `w=-2:h=${options.resolution}`
   }

+ 18 - 8
server/helpers/ffmpeg/ffprobe-utils.ts

@@ -90,15 +90,21 @@ async function getAudioStreamCodec (path: string, existingProbe?: FfprobeData) {
 // Resolutions
 // ---------------------------------------------------------------------------
 
-function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
+function computeResolutionsToTranscode (options: {
+  inputResolution: number
+  type: 'vod' | 'live'
+  includeInputResolution: boolean
+}) {
+  const { inputResolution, type, includeInputResolution } = options
+
   const configResolutions = type === 'vod'
     ? CONFIG.TRANSCODING.RESOLUTIONS
     : CONFIG.LIVE.TRANSCODING.RESOLUTIONS
 
-  const resolutionsEnabled: number[] = []
+  const resolutionsEnabled = new Set<number>()
 
   // Put in the order we want to proceed jobs
-  const resolutions: VideoResolution[] = [
+  const availableResolutions: VideoResolution[] = [
     VideoResolution.H_NOVIDEO,
     VideoResolution.H_480P,
     VideoResolution.H_360P,
@@ -110,13 +116,17 @@ function computeLowerResolutionsToTranscode (videoFileResolution: number, type:
     VideoResolution.H_4K
   ]
 
-  for (const resolution of resolutions) {
-    if (configResolutions[resolution + 'p'] === true && videoFileResolution > resolution) {
-      resolutionsEnabled.push(resolution)
+  for (const resolution of availableResolutions) {
+    if (configResolutions[resolution + 'p'] === true && inputResolution > resolution) {
+      resolutionsEnabled.add(resolution)
     }
   }
 
-  return resolutionsEnabled
+  if (includeInputResolution) {
+    resolutionsEnabled.add(inputResolution)
+  }
+
+  return Array.from(resolutionsEnabled)
 }
 
 // ---------------------------------------------------------------------------
@@ -224,7 +234,7 @@ export {
   computeFPS,
   getClosestFramerateStandard,
 
-  computeLowerResolutionsToTranscode,
+  computeResolutionsToTranscode,
 
   canDoQuickTranscode,
   canDoQuickVideoTranscode,

+ 2 - 2
server/initializers/checker-before-init.ts

@@ -30,7 +30,7 @@ function checkMissedConfig () {
     'transcoding.profile', 'transcoding.concurrency',
     'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p',
     'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
-    'transcoding.resolutions.2160p', 'video_studio.enabled',
+    'transcoding.resolutions.2160p', 'transcoding.always_transcode_original_resolution', 'video_studio.enabled',
     'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'import.videos.timeout',
     'auto_blacklist.videos.of_users.enabled', 'trending.videos.interval_days',
     'client.videos.miniature.display_author_avatar',
@@ -59,7 +59,7 @@ function checkMissedConfig () {
     'live.transcoding.enabled', 'live.transcoding.threads', 'live.transcoding.profile',
     'live.transcoding.resolutions.144p', 'live.transcoding.resolutions.240p', 'live.transcoding.resolutions.360p',
     'live.transcoding.resolutions.480p', 'live.transcoding.resolutions.720p', 'live.transcoding.resolutions.1080p',
-    'live.transcoding.resolutions.1440p', 'live.transcoding.resolutions.2160p'
+    'live.transcoding.resolutions.1440p', 'live.transcoding.resolutions.2160p', 'live.transcoding.always_transcode_original_resolution'
   ]
 
   const requiredAlternatives = [

+ 3 - 0
server/initializers/config.ts

@@ -309,6 +309,7 @@ const CONFIG = {
     get THREADS () { return config.get<number>('transcoding.threads') },
     get CONCURRENCY () { return config.get<number>('transcoding.concurrency') },
     get PROFILE () { return config.get<string>('transcoding.profile') },
+    get ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION () { return config.get<boolean>('transcoding.always_transcode_original_resolution') },
     RESOLUTIONS: {
       get '0p' () { return config.get<boolean>('transcoding.resolutions.0p') },
       get '144p' () { return config.get<boolean>('transcoding.resolutions.144p') },
@@ -361,6 +362,8 @@ const CONFIG = {
       get THREADS () { return config.get<number>('live.transcoding.threads') },
       get PROFILE () { return config.get<string>('live.transcoding.profile') },
 
+      get ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION () { return config.get<boolean>('live.transcoding.always_transcode_original_resolution') },
+
       RESOLUTIONS: {
         get '144p' () { return config.get<boolean>('live.transcoding.resolutions.144p') },
         get '240p' () { return config.get<boolean>('live.transcoding.resolutions.240p') },

+ 1 - 2
server/lib/job-queue/handlers/video-live-ending.ts

@@ -213,13 +213,12 @@ async function assignReplayFilesToVideo (options: {
     const probe = await ffprobePromise(concatenatedTsFilePath)
     const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe)
 
-    const { resolution, isPortraitMode } = await getVideoStreamDimensionsInfo(concatenatedTsFilePath, probe)
+    const { resolution } = await getVideoStreamDimensionsInfo(concatenatedTsFilePath, probe)
 
     const { resolutionPlaylistPath: outputPath } = await generateHlsPlaylistResolutionFromTS({
       video,
       concatenatedTsFilePath,
       resolution,
-      isPortraitMode,
       isAAC: audioStream?.codec_name === 'aac'
     })
 

+ 9 - 17
server/lib/job-queue/handlers/video-transcoding.ts

@@ -1,5 +1,6 @@
 import { Job } from 'bull'
 import { TranscodeVODOptionsType } from '@server/helpers/ffmpeg'
+import { Hooks } from '@server/lib/plugins/hooks'
 import { addTranscodingJob, getTranscodingJobPriority } from '@server/lib/video'
 import { VideoPathManager } from '@server/lib/video-path-manager'
 import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video-state'
@@ -16,7 +17,7 @@ import {
   VideoTranscodingPayload
 } from '@shared/models'
 import { retryTransactionWrapper } from '../../../helpers/database-utils'
-import { computeLowerResolutionsToTranscode } from '../../../helpers/ffmpeg'
+import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg'
 import { logger, loggerTagsFactory } from '../../../helpers/logger'
 import { CONFIG } from '../../../initializers/config'
 import { VideoModel } from '../../../models/video/video'
@@ -26,7 +27,6 @@ import {
   optimizeOriginalVideofile,
   transcodeNewWebTorrentResolution
 } from '../../transcoding/transcoding'
-import { Hooks } from '@server/lib/plugins/hooks'
 
 type HandlerFunction = (job: Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise<void>
 
@@ -99,7 +99,6 @@ async function handleHLSJob (job: Job, payload: HLSTranscodingPayload, video: MV
       videoInputPath,
       resolution: payload.resolution,
       copyCodecs: payload.copyCodecs,
-      isPortraitMode: payload.isPortraitMode || false,
       job
     })
   })
@@ -117,7 +116,7 @@ async function handleNewWebTorrentResolutionJob (
 ) {
   logger.info('Handling WebTorrent transcoding job for %s.', video.uuid, lTags(video.uuid))
 
-  await transcodeNewWebTorrentResolution(video, payload.resolution, payload.isPortraitMode || false, job)
+  await transcodeNewWebTorrentResolution({ video, resolution: payload.resolution, job })
 
   logger.info('WebTorrent transcoding job for %s ended.', video.uuid, lTags(video.uuid))
 
@@ -127,7 +126,7 @@ async function handleNewWebTorrentResolutionJob (
 async function handleWebTorrentMergeAudioJob (job: Job, payload: MergeAudioTranscodingPayload, video: MVideoFullLight, user: MUserId) {
   logger.info('Handling merge audio transcoding job for %s.', video.uuid, lTags(video.uuid))
 
-  await mergeAudioVideofile(video, payload.resolution, job)
+  await mergeAudioVideofile({ video, resolution: payload.resolution, job })
 
   logger.info('Merge audio transcoding job for %s ended.', video.uuid, lTags(video.uuid))
 
@@ -137,7 +136,7 @@ async function handleWebTorrentMergeAudioJob (job: Job, payload: MergeAudioTrans
 async function handleWebTorrentOptimizeJob (job: Job, payload: OptimizeTranscodingPayload, video: MVideoFullLight, user: MUserId) {
   logger.info('Handling optimize transcoding job for %s.', video.uuid, lTags(video.uuid))
 
-  const { transcodeType } = await optimizeOriginalVideofile(video, video.getMaxQualityFile(), job)
+  const { transcodeType } = await optimizeOriginalVideofile({ video, inputVideoFile: video.getMaxQualityFile(), job })
 
   logger.info('Optimize transcoding job for %s ended.', video.uuid, lTags(video.uuid))
 
@@ -161,7 +160,6 @@ async function onHlsPlaylistGeneration (video: MVideoFullLight, user: MUser, pay
       video,
       user,
       videoFileResolution: payload.resolution,
-      isPortraitMode: payload.isPortraitMode,
       hasAudio: payload.hasAudio,
       isNewVideo: payload.isNewVideo ?? true,
       type: 'hls'
@@ -178,7 +176,7 @@ async function onVideoFirstWebTorrentTranscoding (
   transcodeType: TranscodeVODOptionsType,
   user: MUserId
 ) {
-  const { resolution, isPortraitMode, audioStream } = await videoArg.probeMaxQualityFile()
+  const { resolution, audioStream } = await videoArg.probeMaxQualityFile()
 
   // Maybe the video changed in database, refresh it
   const videoDatabase = await VideoModel.loadFull(videoArg.uuid)
@@ -189,7 +187,6 @@ async function onVideoFirstWebTorrentTranscoding (
   const originalFileHLSPayload = {
     ...payload,
 
-    isPortraitMode,
     hasAudio: !!audioStream,
     resolution: videoDatabase.getMaxQualityFile().resolution,
     // If we quick transcoded original file, force transcoding for HLS to avoid some weird playback issues
@@ -202,7 +199,6 @@ async function onVideoFirstWebTorrentTranscoding (
     user,
     videoFileResolution: resolution,
     hasAudio: !!audioStream,
-    isPortraitMode,
     type: 'webtorrent',
     isNewVideo: payload.isNewVideo ?? true
   })
@@ -235,7 +231,6 @@ async function createHlsJobIfEnabled (user: MUserId, payload: {
   videoUUID: string
   resolution: number
   hasAudio: boolean
-  isPortraitMode?: boolean
   copyCodecs: boolean
   isMaxQuality: boolean
   isNewVideo?: boolean
@@ -250,7 +245,7 @@ async function createHlsJobIfEnabled (user: MUserId, payload: {
     type: 'new-resolution-to-hls',
     autoDeleteWebTorrentIfNeeded: true,
 
-    ...pick(payload, [ 'videoUUID', 'resolution', 'isPortraitMode', 'copyCodecs', 'isMaxQuality', 'isNewVideo', 'hasAudio' ])
+    ...pick(payload, [ 'videoUUID', 'resolution', 'copyCodecs', 'isMaxQuality', 'isNewVideo', 'hasAudio' ])
   }
 
   await addTranscodingJob(hlsTranscodingPayload, jobOptions)
@@ -262,16 +257,15 @@ async function createLowerResolutionsJobs (options: {
   video: MVideoFullLight
   user: MUserId
   videoFileResolution: number
-  isPortraitMode: boolean
   hasAudio: boolean
   isNewVideo: boolean
   type: 'hls' | 'webtorrent'
 }) {
-  const { video, user, videoFileResolution, isPortraitMode, isNewVideo, hasAudio, type } = options
+  const { video, user, videoFileResolution, isNewVideo, hasAudio, type } = options
 
   // Create transcoding jobs if there are enabled resolutions
   const resolutionsEnabled = await Hooks.wrapObject(
-    computeLowerResolutionsToTranscode(videoFileResolution, 'vod'),
+    computeResolutionsToTranscode({ inputResolution: videoFileResolution, type: 'vod', includeInputResolution: false }),
     'filter:transcoding.auto.lower-resolutions-to-transcode.result',
     options
   )
@@ -289,7 +283,6 @@ async function createLowerResolutionsJobs (options: {
         type: 'new-resolution-to-webtorrent',
         videoUUID: video.uuid,
         resolution,
-        isPortraitMode,
         hasAudio,
         createHLSIfNeeded: true,
         isNewVideo
@@ -303,7 +296,6 @@ async function createLowerResolutionsJobs (options: {
         type: 'new-resolution-to-hls',
         videoUUID: video.uuid,
         resolution,
-        isPortraitMode,
         hasAudio,
         copyCodecs: false,
         isMaxQuality: false,

+ 10 - 4
server/lib/live/live-manager.ts

@@ -4,7 +4,7 @@ import { createServer, Server } from 'net'
 import { join } from 'path'
 import { createServer as createServerTLS, Server as ServerTLS } from 'tls'
 import {
-  computeLowerResolutionsToTranscode,
+  computeResolutionsToTranscode,
   ffprobePromise,
   getLiveSegmentTime,
   getVideoStreamBitrate,
@@ -26,10 +26,10 @@ import { federateVideoIfNeeded } from '../activitypub/videos'
 import { JobQueue } from '../job-queue'
 import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '../paths'
 import { PeerTubeSocket } from '../peertube-socket'
+import { Hooks } from '../plugins/hooks'
 import { LiveQuotaStore } from './live-quota-store'
 import { cleanupPermanentLive } from './live-utils'
 import { MuxingSession } from './shared'
-import { Hooks } from '../plugins/hooks'
 
 const NodeRtmpSession = require('node-media-server/src/node_rtmp_session')
 const context = require('node-media-server/src/node_core_ctx')
@@ -456,11 +456,17 @@ class LiveManager {
   }
 
   private buildAllResolutionsToTranscode (originResolution: number) {
+    const includeInputResolution = CONFIG.LIVE.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION
+
     const resolutionsEnabled = CONFIG.LIVE.TRANSCODING.ENABLED
-      ? computeLowerResolutionsToTranscode(originResolution, 'live')
+      ? computeResolutionsToTranscode({ inputResolution: originResolution, type: 'live', includeInputResolution })
       : []
 
-    return resolutionsEnabled.concat([ originResolution ])
+    if (resolutionsEnabled.length === 0) {
+      return [ originResolution ]
+    }
+
+    return resolutionsEnabled
   }
 
   private async createLivePlaylist (video: MVideo, allResolutions: number[]): Promise<MStreamingPlaylistVideo> {

+ 38 - 17
server/lib/transcoding/transcoding.ts

@@ -10,6 +10,7 @@ import { VideoResolution, VideoStorage } from '../../../shared/models/videos'
 import {
   buildFileMetadata,
   canDoQuickTranscode,
+  computeResolutionsToTranscode,
   getVideoStreamDuration,
   getVideoStreamFPS,
   transcodeVOD,
@@ -32,7 +33,13 @@ import { VideoTranscodingProfilesManager } from './default-transcoding-profiles'
  */
 
 // Optimize the original video file and replace it. The resolution is not changed.
-function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVideoFile, job?: Job) {
+function optimizeOriginalVideofile (options: {
+  video: MVideoFullLight
+  inputVideoFile: MVideoFile
+  job: Job
+}) {
+  const { video, inputVideoFile, job } = options
+
   const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
   const newExtname = '.mp4'
 
@@ -43,7 +50,7 @@ function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVid
       ? 'quick-transcode'
       : 'video'
 
-    const resolution = toEven(inputVideoFile.resolution)
+    const resolution = buildOriginalFileResolution(inputVideoFile.resolution)
 
     const transcodeOptions: TranscodeVODOptions = {
       type: transcodeType,
@@ -63,6 +70,7 @@ function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVid
     await transcodeVOD(transcodeOptions)
 
     // Important to do this before getVideoFilename() to take in account the new filename
+    inputVideoFile.resolution = resolution
     inputVideoFile.extname = newExtname
     inputVideoFile.filename = generateWebTorrentVideoFilename(resolution, newExtname)
     inputVideoFile.storage = VideoStorage.FILE_SYSTEM
@@ -76,17 +84,22 @@ function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVid
   })
 }
 
-// Transcode the original video file to a lower resolution
-// We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
-function transcodeNewWebTorrentResolution (video: MVideoFullLight, resolution: VideoResolution, isPortrait: boolean, job: Job) {
+// Transcode the original video file to a lower resolution compatible with WebTorrent
+function transcodeNewWebTorrentResolution (options: {
+  video: MVideoFullLight
+  resolution: VideoResolution
+  job: Job
+}) {
+  const { video, resolution, job } = options
+
   const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
-  const extname = '.mp4'
+  const newExtname = '.mp4'
 
   return VideoPathManager.Instance.makeAvailableVideoFile(video.getMaxQualityFile().withVideoOrPlaylist(video), async videoInputPath => {
     const newVideoFile = new VideoFileModel({
       resolution,
-      extname,
-      filename: generateWebTorrentVideoFilename(resolution, extname),
+      extname: newExtname,
+      filename: generateWebTorrentVideoFilename(resolution, newExtname),
       size: 0,
       videoId: video.id
     })
@@ -117,7 +130,6 @@ function transcodeNewWebTorrentResolution (video: MVideoFullLight, resolution: V
         profile: CONFIG.TRANSCODING.PROFILE,
 
         resolution,
-        isPortraitMode: isPortrait,
 
         job
       }
@@ -129,7 +141,13 @@ function transcodeNewWebTorrentResolution (video: MVideoFullLight, resolution: V
 }
 
 // Merge an image with an audio file to create a video
-function mergeAudioVideofile (video: MVideoFullLight, resolution: VideoResolution, job: Job) {
+function mergeAudioVideofile (options: {
+  video: MVideoFullLight
+  resolution: VideoResolution
+  job: Job
+}) {
+  const { video, resolution, job } = options
+
   const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
   const newExtname = '.mp4'
 
@@ -188,13 +206,11 @@ async function generateHlsPlaylistResolutionFromTS (options: {
   video: MVideo
   concatenatedTsFilePath: string
   resolution: VideoResolution
-  isPortraitMode: boolean
   isAAC: boolean
 }) {
   return generateHlsPlaylistCommon({
     video: options.video,
     resolution: options.resolution,
-    isPortraitMode: options.isPortraitMode,
     inputPath: options.concatenatedTsFilePath,
     type: 'hls-from-ts' as 'hls-from-ts',
     isAAC: options.isAAC
@@ -207,14 +223,12 @@ function generateHlsPlaylistResolution (options: {
   videoInputPath: string
   resolution: VideoResolution
   copyCodecs: boolean
-  isPortraitMode: boolean
   job?: Job
 }) {
   return generateHlsPlaylistCommon({
     video: options.video,
     resolution: options.resolution,
     copyCodecs: options.copyCodecs,
-    isPortraitMode: options.isPortraitMode,
     inputPath: options.videoInputPath,
     type: 'hls' as 'hls',
     job: options.job
@@ -267,11 +281,10 @@ async function generateHlsPlaylistCommon (options: {
   resolution: VideoResolution
   copyCodecs?: boolean
   isAAC?: boolean
-  isPortraitMode: boolean
 
   job?: Job
 }) {
-  const { type, video, inputPath, resolution, copyCodecs, isPortraitMode, isAAC, job } = options
+  const { type, video, inputPath, resolution, copyCodecs, isAAC, job } = options
   const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
 
   const videoTranscodedBasePath = join(transcodeDirectory, type)
@@ -292,7 +305,6 @@ async function generateHlsPlaylistCommon (options: {
 
     resolution,
     copyCodecs,
-    isPortraitMode,
 
     isAAC,
 
@@ -350,3 +362,12 @@ async function generateHlsPlaylistCommon (options: {
 
   return { resolutionPlaylistPath, videoFile: savedVideoFile }
 }
+
+function buildOriginalFileResolution (inputResolution: number) {
+  if (CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION === true) return toEven(inputResolution)
+
+  const resolutions = computeResolutionsToTranscode({ inputResolution, type: 'vod', includeInputResolution: false })
+  if (resolutions.length === 0) return toEven(inputResolution)
+
+  return Math.max(...resolutions)
+}

+ 5 - 0
server/middlewares/validators/config.ts

@@ -54,6 +54,9 @@ const customConfigUpdateValidator = [
   body('transcoding.resolutions.1440p').isBoolean().withMessage('Should have a valid transcoding 1440p resolution enabled boolean'),
   body('transcoding.resolutions.2160p').isBoolean().withMessage('Should have a valid transcoding 2160p resolution enabled boolean'),
 
+  body('transcoding.alwaysTranscodeOriginalResolution').isBoolean()
+    .withMessage('Should have a valid always transcode original resolution boolean'),
+
   body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'),
   body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'),
 
@@ -91,6 +94,8 @@ const customConfigUpdateValidator = [
   body('live.transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'),
   body('live.transcoding.resolutions.1440p').isBoolean().withMessage('Should have a valid transcoding 1440p resolution enabled boolean'),
   body('live.transcoding.resolutions.2160p').isBoolean().withMessage('Should have a valid transcoding 2160p resolution enabled boolean'),
+  body('live.transcoding.alwaysTranscodeOriginalResolution').isBoolean()
+    .withMessage('Should have a valid always transcode live original resolution boolean'),
 
   body('search.remoteUri.users').isBoolean().withMessage('Should have a remote URI search for users boolean'),
   body('search.remoteUri.anonymous').isBoolean().withMessage('Should have a valid remote URI search for anonymous boolean'),

+ 3 - 1
server/tests/api/check-params/config.ts

@@ -114,6 +114,7 @@ describe('Test config API validators', function () {
         '1440p': false,
         '2160p': false
       },
+      alwaysTranscodeOriginalResolution: false,
       webtorrent: {
         enabled: true
       },
@@ -145,7 +146,8 @@ describe('Test config API validators', function () {
           '1080p': true,
           '1440p': true,
           '2160p': true
-        }
+        },
+        alwaysTranscodeOriginalResolution: false
       }
     },
     videoStudio: {

+ 68 - 5
server/tests/api/live/live.ts

@@ -4,7 +4,7 @@ import 'mocha'
 import * as chai from 'chai'
 import { basename, join } from 'path'
 import { ffprobePromise, getVideoStream } from '@server/helpers/ffmpeg'
-import { checkLiveCleanup, checkLiveSegmentHash, checkResolutionsInMasterPlaylist, testImage } from '@server/tests/shared'
+import { checkLiveSegmentHash, checkResolutionsInMasterPlaylist, getAllFiles, testImage } from '@server/tests/shared'
 import { wait } from '@shared/core-utils'
 import {
   HttpStatusCode,
@@ -468,7 +468,7 @@ describe('Test live', function () {
       await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
       await waitJobs(servers)
 
-      await testVideoResolutions(liveVideoId, resolutions)
+      await testVideoResolutions(liveVideoId, resolutions.concat([ 720 ]))
 
       await stopFfmpeg(ffmpegCommand)
     })
@@ -580,10 +580,73 @@ describe('Test live', function () {
       }
     })
 
-    it('Should correctly have cleaned up the live files', async function () {
-      this.timeout(30000)
+    it('Should not generate an upper resolution than original file', async function () {
+      this.timeout(400_000)
+
+      const resolutions = [ 240, 480 ]
+      await updateConf(resolutions)
+
+      await servers[0].config.updateExistingSubConfig({
+        newConfig: {
+          live: {
+            transcoding: {
+              alwaysTranscodeOriginalResolution: false
+            }
+          }
+        }
+      })
+
+      liveVideoId = await createLiveWrapper(true)
+
+      const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId, fixtureName: 'video_short2.webm' })
+      await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
+      await waitJobs(servers)
+
+      await testVideoResolutions(liveVideoId, resolutions)
+
+      await stopFfmpeg(ffmpegCommand)
+      await commands[0].waitUntilEnded({ videoId: liveVideoId })
+
+      await waitJobs(servers)
+
+      await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
+
+      const video = await servers[0].videos.get({ id: liveVideoId })
+      const hlsFiles = video.streamingPlaylists[0].files
+
+      expect(video.files).to.have.lengthOf(0)
+      expect(hlsFiles).to.have.lengthOf(resolutions.length)
+
+      // eslint-disable-next-line @typescript-eslint/require-array-sort-compare
+      expect(getAllFiles(video).map(f => f.resolution.id).sort()).to.deep.equal(resolutions)
+    })
+
+    it('Should only keep the original resolution if all resolutions are disabled', async function () {
+      this.timeout(400_000)
+
+      await updateConf([])
+      liveVideoId = await createLiveWrapper(true)
+
+      const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId, fixtureName: 'video_short2.webm' })
+      await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
+      await waitJobs(servers)
+
+      await testVideoResolutions(liveVideoId, [ 720 ])
+
+      await stopFfmpeg(ffmpegCommand)
+      await commands[0].waitUntilEnded({ videoId: liveVideoId })
+
+      await waitJobs(servers)
+
+      await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
+
+      const video = await servers[0].videos.get({ id: liveVideoId })
+      const hlsFiles = video.streamingPlaylists[0].files
+
+      expect(video.files).to.have.lengthOf(0)
+      expect(hlsFiles).to.have.lengthOf(1)
 
-      await checkLiveCleanup(servers[0], liveVideoId, [ 240, 360, 720 ])
+      expect(hlsFiles[0].resolution.id).to.equal(720)
     })
   })
 

+ 7 - 1
server/tests/api/server/config.ts

@@ -77,6 +77,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
   expect(data.transcoding.resolutions['1080p']).to.be.true
   expect(data.transcoding.resolutions['1440p']).to.be.true
   expect(data.transcoding.resolutions['2160p']).to.be.true
+  expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.true
   expect(data.transcoding.webtorrent.enabled).to.be.true
   expect(data.transcoding.hls.enabled).to.be.true
 
@@ -97,6 +98,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
   expect(data.live.transcoding.resolutions['1080p']).to.be.false
   expect(data.live.transcoding.resolutions['1440p']).to.be.false
   expect(data.live.transcoding.resolutions['2160p']).to.be.false
+  expect(data.live.transcoding.alwaysTranscodeOriginalResolution).to.be.true
 
   expect(data.videoStudio.enabled).to.be.false
 
@@ -181,6 +183,7 @@ function checkUpdatedConfig (data: CustomConfig) {
   expect(data.transcoding.resolutions['720p']).to.be.false
   expect(data.transcoding.resolutions['1080p']).to.be.false
   expect(data.transcoding.resolutions['2160p']).to.be.false
+  expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.false
   expect(data.transcoding.hls.enabled).to.be.false
   expect(data.transcoding.webtorrent.enabled).to.be.true
 
@@ -200,6 +203,7 @@ function checkUpdatedConfig (data: CustomConfig) {
   expect(data.live.transcoding.resolutions['720p']).to.be.true
   expect(data.live.transcoding.resolutions['1080p']).to.be.true
   expect(data.live.transcoding.resolutions['2160p']).to.be.true
+  expect(data.live.transcoding.alwaysTranscodeOriginalResolution).to.be.false
 
   expect(data.videoStudio.enabled).to.be.true
 
@@ -318,6 +322,7 @@ const newCustomConfig: CustomConfig = {
       '1440p': false,
       '2160p': false
     },
+    alwaysTranscodeOriginalResolution: false,
     webtorrent: {
       enabled: true
     },
@@ -347,7 +352,8 @@ const newCustomConfig: CustomConfig = {
         '1080p': true,
         '1440p': true,
         '2160p': true
-      }
+      },
+      alwaysTranscodeOriginalResolution: false
     }
   },
   videoStudio: {

+ 78 - 2
server/tests/api/transcoding/transcoder.ts

@@ -7,11 +7,11 @@ import { canDoQuickTranscode } from '@server/helpers/ffmpeg'
 import { generateHighBitrateVideo, generateVideoWithFramerate, getAllFiles } from '@server/tests/shared'
 import { buildAbsoluteFixturePath, getMaxBitrate, getMinLimitBitrate } from '@shared/core-utils'
 import {
-  getAudioStream,
   buildFileMetadata,
+  getAudioStream,
   getVideoStreamBitrate,
-  getVideoStreamFPS,
   getVideoStreamDimensionsInfo,
+  getVideoStreamFPS,
   hasAudioStream
 } from '@shared/extra-utils'
 import { HttpStatusCode, VideoState } from '@shared/models'
@@ -727,6 +727,82 @@ describe('Test video transcoding', function () {
     })
   })
 
+  describe('Bounded transcoding', function () {
+
+    it('Should not generate an upper resolution than original file', async function () {
+      this.timeout(120_000)
+
+      await servers[0].config.updateExistingSubConfig({
+        newConfig: {
+          transcoding: {
+            enabled: true,
+            hls: { enabled: true },
+            webtorrent: { enabled: true },
+            resolutions: {
+              '0p': false,
+              '144p': false,
+              '240p': true,
+              '360p': false,
+              '480p': true,
+              '720p': false,
+              '1080p': false,
+              '1440p': false,
+              '2160p': false
+            },
+            alwaysTranscodeOriginalResolution: false
+          }
+        }
+      })
+
+      const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' })
+      await waitJobs(servers)
+
+      const video = await servers[0].videos.get({ id: uuid })
+      const hlsFiles = video.streamingPlaylists[0].files
+
+      expect(video.files).to.have.lengthOf(2)
+      expect(hlsFiles).to.have.lengthOf(2)
+
+      // eslint-disable-next-line @typescript-eslint/require-array-sort-compare
+      const resolutions = getAllFiles(video).map(f => f.resolution.id).sort()
+      expect(resolutions).to.deep.equal([ 240, 240, 480, 480 ])
+    })
+
+    it('Should only keep the original resolution if all resolutions are disabled', async function () {
+      this.timeout(120_000)
+
+      await servers[0].config.updateExistingSubConfig({
+        newConfig: {
+          transcoding: {
+            resolutions: {
+              '0p': false,
+              '144p': false,
+              '240p': false,
+              '360p': false,
+              '480p': false,
+              '720p': false,
+              '1080p': false,
+              '1440p': false,
+              '2160p': false
+            }
+          }
+        }
+      })
+
+      const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' })
+      await waitJobs(servers)
+
+      const video = await servers[0].videos.get({ id: uuid })
+      const hlsFiles = video.streamingPlaylists[0].files
+
+      expect(video.files).to.have.lengthOf(1)
+      expect(hlsFiles).to.have.lengthOf(1)
+
+      expect(video.files[0].resolution.id).to.equal(720)
+      expect(hlsFiles[0].resolution.id).to.equal(720)
+    })
+  })
+
   after(async function () {
     await cleanupTests(servers)
   })

+ 3 - 0
server/tests/shared/streaming-playlists.ts

@@ -68,6 +68,9 @@ async function checkResolutionsInMasterPlaylist (options: {
 
     expect(masterPlaylist).to.match(reg)
   }
+
+  const playlistsLength = masterPlaylist.split('\n').filter(line => line.startsWith('#EXT-X-STREAM-INF:BANDWIDTH='))
+  expect(playlistsLength).to.have.lengthOf(resolutions.length)
 }
 
 export {

+ 3 - 0
shared/models/server/custom-config.model.ts

@@ -117,6 +117,8 @@ export interface CustomConfig {
 
     resolutions: ConfigResolutions & { '0p': boolean }
 
+    alwaysTranscodeOriginalResolution: boolean
+
     webtorrent: {
       enabled: boolean
     }
@@ -144,6 +146,7 @@ export interface CustomConfig {
       threads: number
       profile: string
       resolutions: ConfigResolutions
+      alwaysTranscodeOriginalResolution: boolean
     }
   }
 

+ 1 - 4
shared/models/server/job.model.ts

@@ -1,7 +1,7 @@
 import { ContextType } from '../activitypub/context'
 import { VideoState } from '../videos'
-import { VideoStudioTaskCut } from '../videos/studio'
 import { VideoResolution } from '../videos/file/video-resolution.enum'
+import { VideoStudioTaskCut } from '../videos/studio'
 import { SendEmailOptions } from './emailer.model'
 
 export type JobState = 'active' | 'completed' | 'failed' | 'waiting' | 'delayed' | 'paused'
@@ -126,7 +126,6 @@ export interface HLSTranscodingPayload extends BaseTranscodingPayload {
   copyCodecs: boolean
 
   hasAudio: boolean
-  isPortraitMode?: boolean
 
   autoDeleteWebTorrentIfNeeded: boolean
   isMaxQuality: boolean
@@ -138,8 +137,6 @@ export interface NewWebTorrentResolutionTranscodingPayload extends BaseTranscodi
 
   hasAudio: boolean
   createHLSIfNeeded: boolean
-
-  isPortraitMode?: boolean
 }
 
 export interface MergeAudioTranscodingPayload extends BaseTranscodingPayload {

+ 3 - 1
shared/server-commands/server/config-command.ts

@@ -310,6 +310,7 @@ export class ConfigCommand extends AbstractCommand {
           '1440p': false,
           '2160p': false
         },
+        alwaysTranscodeOriginalResolution: true,
         webtorrent: {
           enabled: true
         },
@@ -339,7 +340,8 @@ export class ConfigCommand extends AbstractCommand {
             '1080p': true,
             '1440p': true,
             '2160p': true
-          }
+          },
+          alwaysTranscodeOriginalResolution: true
         }
       },
       videoStudio: {