From 2650d6d489f775a38c5c3fdb65daabc7d55c15b5 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 2 Dec 2020 10:07:26 +0100 Subject: [PATCH] Fix live replay duration glitch --- server/helpers/ffmpeg-utils.ts | 76 +++++----- .../job-queue/handlers/video-live-ending.ts | 67 ++++----- server/lib/video-transcoding.ts | 131 +++++++++++++----- server/models/video/video-playlist.ts | 32 ++--- server/tests/api/live/live.ts | 2 + 5 files changed, 176 insertions(+), 132 deletions(-) diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index 085635b5a..c6b8a0eb0 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts @@ -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}` } diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts index 6e1076d8f..55bee0b83 100644 --- a/server/lib/job-queue/handlers/video-live-ending.ts +++ b/server/lib/job-queue/handlers/video-live-ending.ts @@ -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') -} diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts index e022f2a68..890b23a44 100644 --- a/server/lib/video-transcoding.ts +++ b/server/lib/video-transcoding.ts @@ -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 } diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts index b020bfa45..9f9e0b069 100644 --- a/server/models/video/video-playlist.ts +++ b/server/models/video/video-playlist.ts @@ -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', diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts index d0586499b..23f8d2be1 100644 --- a/server/tests/api/live/live.ts +++ b/server/tests/api/live/live.ts @@ -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) -- 2.41.0