// Transcode meta function
// ---------------------------------------------------------------------------
-type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
+type TranscodeOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
interface BaseTranscodeOptions {
type: TranscodeOptionsType
}
}
+interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions {
+ type: 'hls-from-ts'
+
+ hlsPlaylist: {
+ videoFilename: string
+ }
+}
+
interface QuickTranscodeOptions extends BaseTranscodeOptions {
type: 'quick-transcode'
}
type TranscodeOptions =
HLSTranscodeOptions
+ | HLSFromTSTranscodeOptions
| VideoTranscodeOptions
| MergeAudioTranscodeOptions
| OnlyAudioTranscodeOptions
} = {
'quick-transcode': buildQuickTranscodeCommand,
'hls': buildHLSVODCommand,
+ 'hls-from-ts': buildHLSVODFromTSCommand,
'merge-audio': buildAudioMergeCommand,
'only-audio': buildOnlyAudioCommand,
'video': buildx264VODCommand
return command
}
-async function hlsPlaylistToFragmentedMP4 (replayDirectory: string, segmentFiles: string[], outputPath: string) {
- const concatFilePath = join(replayDirectory, 'concat.txt')
-
- function cleaner () {
- remove(concatFilePath)
- .catch(err => logger.error('Cannot remove concat file in %s.', replayDirectory, { err }))
- }
-
- // First concat the ts files to a mp4 file
- const content = segmentFiles.map(f => 'file ' + f)
- .join('\n')
-
- await writeFile(concatFilePath, content + '\n')
-
- const command = getFFmpeg(concatFilePath)
- command.inputOption('-safe 0')
- command.inputOption('-f concat')
-
- command.outputOption('-c:v copy')
- command.audioFilter('aresample=async=1:first_pts=0')
- command.output(outputPath)
-
- return runCommand(command, cleaner)
-}
-
function buildStreamSuffix (base: string, streamNum?: number) {
if (streamNum !== undefined) {
return `${base}:${streamNum}`
generateImageFromVideoFile,
TranscodeOptions,
TranscodeOptionsType,
- transcode,
- hlsPlaylistToFragmentedMP4
+ transcode
}
// ---------------------------------------------------------------------------
return command
}
+function addCommonHLSVODCommandOptions (command: ffmpeg.FfmpegCommand, outputPath: string) {
+ return command.outputOption('-hls_time 4')
+ .outputOption('-hls_list_size 0')
+ .outputOption('-hls_playlist_type vod')
+ .outputOption('-hls_segment_filename ' + outputPath)
+ .outputOption('-hls_segment_type fmp4')
+ .outputOption('-f hls')
+ .outputOption('-hls_flags single_file')
+}
+
async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
const videoPath = getHLSVideoPath(options)
else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
else command = await buildx264VODCommand(command, options)
- command = command.outputOption('-hls_time 4')
- .outputOption('-hls_list_size 0')
- .outputOption('-hls_playlist_type vod')
- .outputOption('-hls_segment_filename ' + videoPath)
- .outputOption('-hls_segment_type fmp4')
- .outputOption('-f hls')
- .outputOption('-hls_flags single_file')
+ addCommonHLSVODCommandOptions(command, videoPath)
+
+ return command
+}
+
+async function buildHLSVODFromTSCommand (command: ffmpeg.FfmpegCommand, options: HLSFromTSTranscodeOptions) {
+ const videoPath = getHLSVideoPath(options)
+
+ command.inputOption('-safe 0')
+ command.inputOption('-f concat')
+
+ command.outputOption('-c:v copy')
+ command.audioFilter('aresample=async=1:first_pts=0')
+
+ addCommonHLSVODCommandOptions(command, videoPath)
return command
}
async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
- if (options.type !== 'hls') return
+ if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
const fileContent = await readFile(options.outputPath)
await writeFile(options.outputPath, newContent)
}
-function getHLSVideoPath (options: HLSTranscodeOptions) {
+function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
}
import * as Bull from 'bull'
import { copy, readdir, remove } from 'fs-extra'
import { join } from 'path'
-import { hlsPlaylistToFragmentedMP4 } from '@server/helpers/ffmpeg-utils'
import { getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils'
import { VIDEO_LIVE } from '@server/initializers/constants'
import { generateVideoMiniature } from '@server/lib/thumbnail'
import { publishAndFederateIfNeeded } from '@server/lib/video'
import { getHLSDirectory } from '@server/lib/video-paths'
-import { generateHlsPlaylist } from '@server/lib/video-transcoding'
+import { generateHlsPlaylistFromTS } from '@server/lib/video-transcoding'
import { VideoModel } from '@server/models/video/video'
import { VideoFileModel } from '@server/models/video/video-file'
import { VideoLiveModel } from '@server/models/video/video-live'
}
}
- const replayFiles = await readdir(replayDirectory)
-
- const resolutions: number[] = []
- let duration: number
-
- for (const playlistFile of playlistFiles) {
- const playlistPath = join(replayDirectory, playlistFile)
- const { videoFileResolution } = await getVideoFileResolution(playlistPath)
-
- // Put the final mp4 in the hls directory, and not in the replay directory
- const mp4TmpPath = buildMP4TmpPath(hlsDirectory, videoFileResolution)
-
- // Playlist name is for example 3.m3u8
- // Segments names are 3-0.ts 3-1.ts etc
- const shouldStartWith = playlistFile.replace(/\.m3u8$/, '') + '-'
-
- const segmentFiles = replayFiles.filter(f => f.startsWith(shouldStartWith) && f.endsWith('.ts'))
- await hlsPlaylistToFragmentedMP4(replayDirectory, segmentFiles, mp4TmpPath)
-
- if (!duration) {
- duration = await getDurationFromVideoFile(mp4TmpPath)
- }
-
- resolutions.push(videoFileResolution)
- }
-
await cleanupLiveFiles(hlsDirectory)
await live.destroy()
// Reinit views
video.views = 0
video.state = VideoState.TO_TRANSCODE
- video.duration = duration
await video.save()
await VideoFileModel.removeHLSFilesOfVideoId(hlsPlaylist.id)
hlsPlaylist.VideoFiles = []
- for (const resolution of resolutions) {
- const videoInputPath = buildMP4TmpPath(hlsDirectory, resolution)
- const { isPortraitMode } = await getVideoFileResolution(videoInputPath)
+ const replayFiles = await readdir(replayDirectory)
+ let duration: number
- await generateHlsPlaylist({
+ for (const playlistFile of playlistFiles) {
+ const playlistPath = join(replayDirectory, playlistFile)
+ const { videoFileResolution, isPortraitMode } = await getVideoFileResolution(playlistPath)
+
+ // Playlist name is for example 3.m3u8
+ // Segments names are 3-0.ts 3-1.ts etc
+ const shouldStartWith = playlistFile.replace(/\.m3u8$/, '') + '-'
+
+ const segmentFiles = replayFiles.filter(f => f.startsWith(shouldStartWith) && f.endsWith('.ts'))
+
+ const outputPath = await generateHlsPlaylistFromTS({
video: videoWithFiles,
- videoInputPath,
- resolution: resolution,
- copyCodecs: true,
+ replayDirectory,
+ segmentFiles,
+ resolution: videoFileResolution,
isPortraitMode
})
- await remove(videoInputPath)
+ if (!duration) {
+ videoWithFiles.duration = await getDurationFromVideoFile(outputPath)
+ await videoWithFiles.save()
+ }
}
+ await remove(replayDirectory)
+
// Regenerate the thumbnail & preview?
if (videoWithFiles.getMiniature().automaticallyGenerated === true) {
await generateVideoMiniature(videoWithFiles, videoWithFiles.getMaxQualityFile(), ThumbnailType.MINIATURE)
filename.endsWith('.m3u8') ||
filename.endsWith('.mpd') ||
filename.endsWith('.m4s') ||
- filename.endsWith('.tmp') ||
- filename === VIDEO_LIVE.REPLAY_DIRECTORY
+ filename.endsWith('.tmp')
) {
const p = join(hlsDirectory, filename)
}
}
}
-
-function buildMP4TmpPath (basePath: string, resolution: number) {
- return join(basePath, resolution + '-tmp.mp4')
-}
-import { copyFile, ensureDir, move, remove, stat } from 'fs-extra'
+import { copyFile, ensureDir, move, remove, stat, writeFile } from 'fs-extra'
import { basename, extname as extnameUtil, join } from 'path'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models'
return onVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
}
+// Concat TS segments from a live video to a fragmented mp4 HLS playlist
+async function generateHlsPlaylistFromTS (options: {
+ video: MVideoWithFile
+ replayDirectory: string
+ segmentFiles: string[]
+ resolution: VideoResolution
+ isPortraitMode: boolean
+}) {
+ const concatFilePath = join(options.replayDirectory, 'concat.txt')
+
+ function cleaner () {
+ remove(concatFilePath)
+ .catch(err => logger.error('Cannot remove concat file in %s.', options.replayDirectory, { err }))
+ }
+
+ // First concat the ts files to a mp4 file
+ const content = options.segmentFiles.map(f => 'file ' + f)
+ .join('\n')
+
+ await writeFile(concatFilePath, content + '\n')
+
+ try {
+ const outputPath = await generateHlsPlaylistCommon({
+ video: options.video,
+ resolution: options.resolution,
+ isPortraitMode: options.isPortraitMode,
+ inputPath: concatFilePath,
+ type: 'hls-from-ts' as 'hls-from-ts'
+ })
+
+ cleaner()
+
+ return outputPath
+ } catch (err) {
+ cleaner()
+
+ throw err
+ }
+}
+
// Generate an HLS playlist from an input file, and update the master playlist
-async function generateHlsPlaylist (options: {
+function generateHlsPlaylist (options: {
video: MVideoWithFile
videoInputPath: string
resolution: VideoResolution
copyCodecs: boolean
isPortraitMode: boolean
}) {
- const { video, videoInputPath, resolution, copyCodecs, isPortraitMode } = options
+ return generateHlsPlaylistCommon({
+ video: options.video,
+ resolution: options.resolution,
+ copyCodecs: options.copyCodecs,
+ isPortraitMode: options.isPortraitMode,
+ inputPath: options.videoInputPath,
+ type: 'hls' as 'hls'
+ })
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ generateHlsPlaylist,
+ generateHlsPlaylistFromTS,
+ optimizeOriginalVideofile,
+ transcodeNewResolution,
+ mergeAudioVideofile
+}
+
+// ---------------------------------------------------------------------------
+
+async function onVideoFileTranscoding (video: MVideoWithFile, videoFile: MVideoFile, transcodingPath: string, outputPath: string) {
+ const stats = await stat(transcodingPath)
+ const fps = await getVideoFileFPS(transcodingPath)
+ const metadata = await getMetadataFromFile(transcodingPath)
+
+ await move(transcodingPath, outputPath, { overwrite: true })
+
+ videoFile.size = stats.size
+ videoFile.fps = fps
+ videoFile.metadata = metadata
+
+ await createTorrentAndSetInfoHash(video, videoFile)
+
+ await VideoFileModel.customUpsert(videoFile, 'video', undefined)
+ video.VideoFiles = await video.$get('VideoFiles')
+
+ return video
+}
+
+async function generateHlsPlaylistCommon (options: {
+ type: 'hls' | 'hls-from-ts'
+ video: MVideoWithFile
+ inputPath: string
+ resolution: VideoResolution
+ copyCodecs?: boolean
+ isPortraitMode: boolean
+}) {
+ const { type, video, inputPath, resolution, copyCodecs, isPortraitMode } = options
const baseHlsDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid)
await ensureDir(join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid))
const videoFilename = generateVideoStreamingPlaylistName(video.uuid, resolution)
const transcodeOptions = {
- type: 'hls' as 'hls',
+ type,
- inputPath: videoInputPath,
+ inputPath,
outputPath,
availableEncoders,
await updateMasterHLSPlaylist(video)
await updateSha256VODSegments(video)
- return video
-}
-
-// ---------------------------------------------------------------------------
-
-export {
- generateHlsPlaylist,
- optimizeOriginalVideofile,
- transcodeNewResolution,
- mergeAudioVideofile
-}
-
-// ---------------------------------------------------------------------------
-
-async function onVideoFileTranscoding (video: MVideoWithFile, videoFile: MVideoFile, transcodingPath: string, outputPath: string) {
- const stats = await stat(transcodingPath)
- const fps = await getVideoFileFPS(transcodingPath)
- const metadata = await getMetadataFromFile(transcodingPath)
-
- await move(transcodingPath, outputPath, { overwrite: true })
-
- videoFile.size = stats.size
- videoFile.fps = fps
- videoFile.metadata = metadata
-
- await createTorrentAndSetInfoHash(video, videoFile)
-
- await VideoFileModel.customUpsert(videoFile, 'video', undefined)
- video.VideoFiles = await video.$get('VideoFiles')
-
- return video
+ return outputPath
}
+import * as Bluebird from 'bluebird'
+import { join } from 'path'
+import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
import {
AllowNull,
BelongsTo,
Table,
UpdatedAt
} from 'sequelize-typescript'
+import { MAccountId, MChannelId } from '@server/types/models'
+import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
+import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
-import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getPlaylistSort, getPlaylistSort, isOutdated, throwIfNotValid } from '../utils'
+import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
+import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
+import { activityPubCollectionPagination } from '../../helpers/activitypub'
+import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
import {
isVideoPlaylistDescriptionValid,
isVideoPlaylistNameValid,
isVideoPlaylistPrivacyValid
} from '../../helpers/custom-validators/video-playlists'
-import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
import {
ACTIVITY_PUB,
CONSTRAINTS_FIELDS,
VIDEO_PLAYLIST_TYPES,
WEBSERVER
} from '../../initializers/constants'
-import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
-import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
-import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
-import { join } from 'path'
-import { VideoPlaylistElementModel } from './video-playlist-element'
-import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
-import { activityPubCollectionPagination } from '../../helpers/activitypub'
-import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
-import { ThumbnailModel } from './thumbnail'
-import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
-import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
-import * as Bluebird from 'bluebird'
+import { MThumbnail } from '../../types/models/video/thumbnail'
import {
MVideoPlaylistAccountThumbnail,
MVideoPlaylistAP,
MVideoPlaylistFullSummary,
MVideoPlaylistIdWithElements
} from '../../types/models/video/video-playlist'
-import { MThumbnail } from '../../types/models/video/thumbnail'
-import { MAccountId, MChannelId } from '@server/types/models'
+import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
+import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getPlaylistSort, isOutdated, throwIfNotValid } from '../utils'
+import { ThumbnailModel } from './thumbnail'
+import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
+import { VideoPlaylistElementModel } from './video-playlist-element'
enum ScopeNames {
AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
expect(video.files).to.have.lengthOf(0)
const hlsPlaylist = video.streamingPlaylists.find(s => s.type === VideoStreamingPlaylistType.HLS)
+ await makeRawRequest(hlsPlaylist.playlistUrl, 200)
+ await makeRawRequest(hlsPlaylist.segmentsSha256Url, 200)
expect(hlsPlaylist.files).to.have.lengthOf(resolutions.length)