Fix live replay duration glitch

pull/3388/head
Chocobozzz 2020-12-02 10:07:26 +01:00
parent 543e187262
commit 2650d6d489
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
5 changed files with 176 additions and 132 deletions

View File

@ -110,7 +110,7 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima
// 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
@ -134,6 +134,14 @@ interface HLSTranscodeOptions extends BaseTranscodeOptions {
}
}
interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions {
type: 'hls-from-ts'
hlsPlaylist: {
videoFilename: string
}
}
interface QuickTranscodeOptions extends BaseTranscodeOptions {
type: 'quick-transcode'
}
@ -153,6 +161,7 @@ interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions {
type TranscodeOptions =
HLSTranscodeOptions
| HLSFromTSTranscodeOptions
| VideoTranscodeOptions
| MergeAudioTranscodeOptions
| OnlyAudioTranscodeOptions
@ -163,6 +172,7 @@ const builders: {
} = {
'quick-transcode': buildQuickTranscodeCommand,
'hls': buildHLSVODCommand,
'hls-from-ts': buildHLSVODFromTSCommand,
'merge-audio': buildAudioMergeCommand,
'only-audio': buildOnlyAudioCommand,
'video': buildx264VODCommand
@ -292,31 +302,6 @@ function getLiveMuxingCommand (rtmpUrl: string, outPath: string) {
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}`
@ -336,8 +321,7 @@ export {
generateImageFromVideoFile,
TranscodeOptions,
TranscodeOptionsType,
transcode,
hlsPlaylistToFragmentedMP4
transcode
}
// ---------------------------------------------------------------------------
@ -447,6 +431,16 @@ function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
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)
@ -454,19 +448,27 @@ async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTr
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)
@ -480,7 +482,7 @@ async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
await writeFile(options.outputPath, newContent)
}
function getHLSVideoPath (options: HLSTranscodeOptions) {
function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
}

View File

@ -1,13 +1,12 @@
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'
@ -71,32 +70,6 @@ async function saveLive (video: MVideo, live: MVideoLive) {
}
}
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()
@ -105,7 +78,6 @@ async function saveLive (video: MVideo, live: MVideoLive) {
// Reinit views
video.views = 0
video.state = VideoState.TO_TRANSCODE
video.duration = duration
await video.save()
@ -116,21 +88,35 @@ async function saveLive (video: MVideo, live: MVideoLive) {
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)
@ -161,8 +147,7 @@ async function cleanupLiveFiles (hlsDirectory: string) {
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)
@ -171,7 +156,3 @@ async function cleanupLiveFiles (hlsDirectory: string) {
}
}
}
function buildMP4TmpPath (basePath: string, resolution: number) {
return join(basePath, resolution + '-tmp.mp4')
}

View File

@ -1,4 +1,4 @@
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'
@ -163,15 +163,104 @@ async function mergeAudioVideofile (video: MVideoWithAllFiles, resolution: Video
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))
@ -180,9 +269,9 @@ async function generateHlsPlaylist (options: {
const videoFilename = generateVideoStreamingPlaylistName(video.uuid, resolution)
const transcodeOptions = {
type: 'hls' as 'hls',
type,
inputPath: videoInputPath,
inputPath,
outputPath,
availableEncoders,
@ -242,35 +331,5 @@ async function generateHlsPlaylist (options: {
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
}

View File

@ -1,3 +1,6 @@
import * as Bluebird from 'bluebird'
import { join } from 'path'
import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
import {
AllowNull,
BelongsTo,
@ -15,14 +18,19 @@ import {
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,
@ -32,18 +40,7 @@ import {
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,
@ -52,8 +49,11 @@ import {
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',

View File

@ -430,6 +430,8 @@ describe('Test live', function () {
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)