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 // 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 { interface BaseTranscodeOptions {
type: TranscodeOptionsType 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 { interface QuickTranscodeOptions extends BaseTranscodeOptions {
type: 'quick-transcode' type: 'quick-transcode'
} }
@ -153,6 +161,7 @@ interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions {
type TranscodeOptions = type TranscodeOptions =
HLSTranscodeOptions HLSTranscodeOptions
| HLSFromTSTranscodeOptions
| VideoTranscodeOptions | VideoTranscodeOptions
| MergeAudioTranscodeOptions | MergeAudioTranscodeOptions
| OnlyAudioTranscodeOptions | OnlyAudioTranscodeOptions
@ -163,6 +172,7 @@ const builders: {
} = { } = {
'quick-transcode': buildQuickTranscodeCommand, 'quick-transcode': buildQuickTranscodeCommand,
'hls': buildHLSVODCommand, 'hls': buildHLSVODCommand,
'hls-from-ts': buildHLSVODFromTSCommand,
'merge-audio': buildAudioMergeCommand, 'merge-audio': buildAudioMergeCommand,
'only-audio': buildOnlyAudioCommand, 'only-audio': buildOnlyAudioCommand,
'video': buildx264VODCommand 'video': buildx264VODCommand
@ -292,31 +302,6 @@ function getLiveMuxingCommand (rtmpUrl: string, outPath: string) {
return command 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) { function buildStreamSuffix (base: string, streamNum?: number) {
if (streamNum !== undefined) { if (streamNum !== undefined) {
return `${base}:${streamNum}` return `${base}:${streamNum}`
@ -336,8 +321,7 @@ export {
generateImageFromVideoFile, generateImageFromVideoFile,
TranscodeOptions, TranscodeOptions,
TranscodeOptionsType, TranscodeOptionsType,
transcode, transcode
hlsPlaylistToFragmentedMP4
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -447,6 +431,16 @@ function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
return command 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) { async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
const videoPath = getHLSVideoPath(options) 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 if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
else command = await buildx264VODCommand(command, options) else command = await buildx264VODCommand(command, options)
command = command.outputOption('-hls_time 4') addCommonHLSVODCommandOptions(command, videoPath)
.outputOption('-hls_list_size 0')
.outputOption('-hls_playlist_type vod') return command
.outputOption('-hls_segment_filename ' + videoPath) }
.outputOption('-hls_segment_type fmp4')
.outputOption('-f hls') async function buildHLSVODFromTSCommand (command: ffmpeg.FfmpegCommand, options: HLSFromTSTranscodeOptions) {
.outputOption('-hls_flags single_file') 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 return command
} }
async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) { 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) const fileContent = await readFile(options.outputPath)
@ -480,7 +482,7 @@ async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
await writeFile(options.outputPath, newContent) await writeFile(options.outputPath, newContent)
} }
function getHLSVideoPath (options: HLSTranscodeOptions) { function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
} }

View File

@ -1,13 +1,12 @@
import * as Bull from 'bull' import * as Bull from 'bull'
import { copy, readdir, remove } from 'fs-extra' import { copy, readdir, remove } from 'fs-extra'
import { join } from 'path' import { join } from 'path'
import { hlsPlaylistToFragmentedMP4 } from '@server/helpers/ffmpeg-utils'
import { getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils' import { getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils'
import { VIDEO_LIVE } from '@server/initializers/constants' import { VIDEO_LIVE } from '@server/initializers/constants'
import { generateVideoMiniature } from '@server/lib/thumbnail' import { generateVideoMiniature } from '@server/lib/thumbnail'
import { publishAndFederateIfNeeded } from '@server/lib/video' import { publishAndFederateIfNeeded } from '@server/lib/video'
import { getHLSDirectory } from '@server/lib/video-paths' 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 { VideoModel } from '@server/models/video/video'
import { VideoFileModel } from '@server/models/video/video-file' import { VideoFileModel } from '@server/models/video/video-file'
import { VideoLiveModel } from '@server/models/video/video-live' 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 cleanupLiveFiles(hlsDirectory)
await live.destroy() await live.destroy()
@ -105,7 +78,6 @@ async function saveLive (video: MVideo, live: MVideoLive) {
// Reinit views // Reinit views
video.views = 0 video.views = 0
video.state = VideoState.TO_TRANSCODE video.state = VideoState.TO_TRANSCODE
video.duration = duration
await video.save() await video.save()
@ -116,20 +88,34 @@ async function saveLive (video: MVideo, live: MVideoLive) {
await VideoFileModel.removeHLSFilesOfVideoId(hlsPlaylist.id) await VideoFileModel.removeHLSFilesOfVideoId(hlsPlaylist.id)
hlsPlaylist.VideoFiles = [] hlsPlaylist.VideoFiles = []
for (const resolution of resolutions) { const replayFiles = await readdir(replayDirectory)
const videoInputPath = buildMP4TmpPath(hlsDirectory, resolution) let duration: number
const { isPortraitMode } = await getVideoFileResolution(videoInputPath)
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, video: videoWithFiles,
videoInputPath, replayDirectory,
resolution: resolution, segmentFiles,
copyCodecs: true, resolution: videoFileResolution,
isPortraitMode isPortraitMode
}) })
await remove(videoInputPath) if (!duration) {
videoWithFiles.duration = await getDurationFromVideoFile(outputPath)
await videoWithFiles.save()
} }
}
await remove(replayDirectory)
// Regenerate the thumbnail & preview? // Regenerate the thumbnail & preview?
if (videoWithFiles.getMiniature().automaticallyGenerated === true) { if (videoWithFiles.getMiniature().automaticallyGenerated === true) {
@ -161,8 +147,7 @@ async function cleanupLiveFiles (hlsDirectory: string) {
filename.endsWith('.m3u8') || filename.endsWith('.m3u8') ||
filename.endsWith('.mpd') || filename.endsWith('.mpd') ||
filename.endsWith('.m4s') || filename.endsWith('.m4s') ||
filename.endsWith('.tmp') || filename.endsWith('.tmp')
filename === VIDEO_LIVE.REPLAY_DIRECTORY
) { ) {
const p = join(hlsDirectory, filename) 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 { basename, extname as extnameUtil, join } from 'path'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models' 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) 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 // Generate an HLS playlist from an input file, and update the master playlist
async function generateHlsPlaylist (options: { function generateHlsPlaylist (options: {
video: MVideoWithFile video: MVideoWithFile
videoInputPath: string videoInputPath: string
resolution: VideoResolution resolution: VideoResolution
copyCodecs: boolean copyCodecs: boolean
isPortraitMode: 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) const baseHlsDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid)
await ensureDir(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 videoFilename = generateVideoStreamingPlaylistName(video.uuid, resolution)
const transcodeOptions = { const transcodeOptions = {
type: 'hls' as 'hls', type,
inputPath: videoInputPath, inputPath,
outputPath, outputPath,
availableEncoders, availableEncoders,
@ -242,35 +331,5 @@ async function generateHlsPlaylist (options: {
await updateMasterHLSPlaylist(video) await updateMasterHLSPlaylist(video)
await updateSha256VODSegments(video) await updateSha256VODSegments(video)
return video return outputPath
}
// ---------------------------------------------------------------------------
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
} }

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 { import {
AllowNull, AllowNull,
BelongsTo, BelongsTo,
@ -15,14 +18,19 @@ import {
Table, Table,
UpdatedAt UpdatedAt
} from 'sequelize-typescript' } 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 { 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 { import {
isVideoPlaylistDescriptionValid, isVideoPlaylistDescriptionValid,
isVideoPlaylistNameValid, isVideoPlaylistNameValid,
isVideoPlaylistPrivacyValid isVideoPlaylistPrivacyValid
} from '../../helpers/custom-validators/video-playlists' } from '../../helpers/custom-validators/video-playlists'
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
import { import {
ACTIVITY_PUB, ACTIVITY_PUB,
CONSTRAINTS_FIELDS, CONSTRAINTS_FIELDS,
@ -32,18 +40,7 @@ import {
VIDEO_PLAYLIST_TYPES, VIDEO_PLAYLIST_TYPES,
WEBSERVER WEBSERVER
} from '../../initializers/constants' } from '../../initializers/constants'
import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model' import { MThumbnail } from '../../types/models/video/thumbnail'
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 { import {
MVideoPlaylistAccountThumbnail, MVideoPlaylistAccountThumbnail,
MVideoPlaylistAP, MVideoPlaylistAP,
@ -52,8 +49,11 @@ import {
MVideoPlaylistFullSummary, MVideoPlaylistFullSummary,
MVideoPlaylistIdWithElements MVideoPlaylistIdWithElements
} from '../../types/models/video/video-playlist' } from '../../types/models/video/video-playlist'
import { MThumbnail } from '../../types/models/video/thumbnail' import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
import { MAccountId, MChannelId } from '@server/types/models' 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 { enum ScopeNames {
AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',

View File

@ -430,6 +430,8 @@ describe('Test live', function () {
expect(video.files).to.have.lengthOf(0) expect(video.files).to.have.lengthOf(0)
const hlsPlaylist = video.streamingPlaylists.find(s => s.type === VideoStreamingPlaylistType.HLS) 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) expect(hlsPlaylist.files).to.have.lengthOf(resolutions.length)