2023-04-21 14:55:10 +02:00
|
|
|
import { Job } from 'bullmq'
|
2023-07-31 14:34:36 +02:00
|
|
|
import { move, remove } from 'fs-extra/esm'
|
|
|
|
import { copyFile, stat } from 'fs/promises'
|
2023-04-21 14:55:10 +02:00
|
|
|
import { basename, join } from 'path'
|
2023-07-31 14:34:36 +02:00
|
|
|
import { VideoStorage } from '@peertube/peertube-models'
|
|
|
|
import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js'
|
|
|
|
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent.js'
|
|
|
|
import { VideoModel } from '@server/models/video/video.js'
|
|
|
|
import { MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
|
|
|
|
import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, TranscodeVODOptionsType } from '@peertube/peertube-ffmpeg'
|
|
|
|
import { CONFIG } from '../../initializers/config.js'
|
|
|
|
import { VideoFileModel } from '../../models/video/video-file.js'
|
|
|
|
import { JobQueue } from '../job-queue/index.js'
|
|
|
|
import { generateWebVideoFilename } from '../paths.js'
|
|
|
|
import { buildFileMetadata } from '../video-file.js'
|
|
|
|
import { VideoPathManager } from '../video-path-manager.js'
|
|
|
|
import { buildFFmpegVOD } from './shared/index.js'
|
|
|
|
import { buildOriginalFileResolution } from './transcoding-resolutions.js'
|
2023-12-27 10:39:09 +01:00
|
|
|
import { buildStoryboardJobIfNeeded } from '../video.js'
|
2023-04-21 14:55:10 +02:00
|
|
|
|
|
|
|
// Optimize the original video file and replace it. The resolution is not changed.
|
|
|
|
export async function optimizeOriginalVideofile (options: {
|
|
|
|
video: MVideoFullLight
|
|
|
|
inputVideoFile: MVideoFile
|
|
|
|
quickTranscode: boolean
|
|
|
|
job: Job
|
|
|
|
}) {
|
|
|
|
const { video, inputVideoFile, quickTranscode, job } = options
|
|
|
|
|
|
|
|
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
|
|
|
|
const newExtname = '.mp4'
|
|
|
|
|
|
|
|
// Will be released by our transcodeVOD function once ffmpeg is ran
|
|
|
|
const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
|
|
|
|
|
|
|
|
try {
|
|
|
|
await video.reload()
|
2023-04-21 16:31:04 +02:00
|
|
|
await inputVideoFile.reload()
|
2023-04-21 14:55:10 +02:00
|
|
|
|
|
|
|
const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video)
|
|
|
|
|
|
|
|
const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async videoInputPath => {
|
|
|
|
const videoOutputPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
|
|
|
|
|
|
|
|
const transcodeType: TranscodeVODOptionsType = quickTranscode
|
|
|
|
? 'quick-transcode'
|
|
|
|
: 'video'
|
|
|
|
|
|
|
|
const resolution = buildOriginalFileResolution(inputVideoFile.resolution)
|
|
|
|
const fps = computeOutputFPS({ inputFPS: inputVideoFile.fps, resolution })
|
|
|
|
|
|
|
|
// Could be very long!
|
|
|
|
await buildFFmpegVOD(job).transcode({
|
|
|
|
type: transcodeType,
|
|
|
|
|
|
|
|
inputPath: videoInputPath,
|
|
|
|
outputPath: videoOutputPath,
|
|
|
|
|
|
|
|
inputFileMutexReleaser,
|
|
|
|
|
|
|
|
resolution,
|
|
|
|
fps
|
|
|
|
})
|
|
|
|
|
|
|
|
// Important to do this before getVideoFilename() to take in account the new filename
|
|
|
|
inputVideoFile.resolution = resolution
|
|
|
|
inputVideoFile.extname = newExtname
|
2023-07-11 09:21:13 +02:00
|
|
|
inputVideoFile.filename = generateWebVideoFilename(resolution, newExtname)
|
2023-04-21 14:55:10 +02:00
|
|
|
inputVideoFile.storage = VideoStorage.FILE_SYSTEM
|
|
|
|
|
2023-07-11 09:21:13 +02:00
|
|
|
const { videoFile } = await onWebVideoFileTranscoding({
|
2023-04-21 14:55:10 +02:00
|
|
|
video,
|
|
|
|
videoFile: inputVideoFile,
|
|
|
|
videoOutputPath
|
|
|
|
})
|
|
|
|
|
|
|
|
await remove(videoInputPath)
|
|
|
|
|
|
|
|
return { transcodeType, videoFile }
|
|
|
|
})
|
|
|
|
|
|
|
|
return result
|
|
|
|
} finally {
|
|
|
|
inputFileMutexReleaser()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-11 09:21:13 +02:00
|
|
|
// Transcode the original video file to a lower resolution compatible with web browsers
|
|
|
|
export async function transcodeNewWebVideoResolution (options: {
|
2023-04-21 14:55:10 +02:00
|
|
|
video: MVideoFullLight
|
2023-07-31 14:34:36 +02:00
|
|
|
resolution: number
|
2023-04-21 14:55:10 +02:00
|
|
|
fps: number
|
|
|
|
job: Job
|
|
|
|
}) {
|
2023-04-21 16:31:04 +02:00
|
|
|
const { video: videoArg, resolution, fps, job } = options
|
2023-04-21 14:55:10 +02:00
|
|
|
|
|
|
|
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
|
|
|
|
const newExtname = '.mp4'
|
|
|
|
|
2023-04-21 16:31:04 +02:00
|
|
|
const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(videoArg.uuid)
|
2023-04-21 14:55:10 +02:00
|
|
|
|
|
|
|
try {
|
2023-04-21 16:31:04 +02:00
|
|
|
const video = await VideoModel.loadFull(videoArg.uuid)
|
2023-04-21 14:55:10 +02:00
|
|
|
const file = video.getMaxQualityFile().withVideoOrPlaylist(video)
|
|
|
|
|
|
|
|
const result = await VideoPathManager.Instance.makeAvailableVideoFile(file, async videoInputPath => {
|
|
|
|
const newVideoFile = new VideoFileModel({
|
|
|
|
resolution,
|
|
|
|
extname: newExtname,
|
2023-07-11 09:21:13 +02:00
|
|
|
filename: generateWebVideoFilename(resolution, newExtname),
|
2023-04-21 14:55:10 +02:00
|
|
|
size: 0,
|
|
|
|
videoId: video.id
|
|
|
|
})
|
|
|
|
|
|
|
|
const videoOutputPath = join(transcodeDirectory, newVideoFile.filename)
|
|
|
|
|
|
|
|
const transcodeOptions = {
|
|
|
|
type: 'video' as 'video',
|
|
|
|
|
|
|
|
inputPath: videoInputPath,
|
|
|
|
outputPath: videoOutputPath,
|
|
|
|
|
|
|
|
inputFileMutexReleaser,
|
|
|
|
|
|
|
|
resolution,
|
|
|
|
fps
|
|
|
|
}
|
|
|
|
|
|
|
|
await buildFFmpegVOD(job).transcode(transcodeOptions)
|
|
|
|
|
2023-07-11 09:21:13 +02:00
|
|
|
return onWebVideoFileTranscoding({ video, videoFile: newVideoFile, videoOutputPath })
|
2023-04-21 14:55:10 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
return result
|
|
|
|
} finally {
|
|
|
|
inputFileMutexReleaser()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Merge an image with an audio file to create a video
|
|
|
|
export async function mergeAudioVideofile (options: {
|
|
|
|
video: MVideoFullLight
|
2023-07-31 14:34:36 +02:00
|
|
|
resolution: number
|
2023-04-21 14:55:10 +02:00
|
|
|
fps: number
|
|
|
|
job: Job
|
|
|
|
}) {
|
2023-04-21 16:31:04 +02:00
|
|
|
const { video: videoArg, resolution, fps, job } = options
|
2023-04-21 14:55:10 +02:00
|
|
|
|
|
|
|
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
|
|
|
|
const newExtname = '.mp4'
|
|
|
|
|
2023-04-21 16:31:04 +02:00
|
|
|
const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(videoArg.uuid)
|
2023-04-21 14:55:10 +02:00
|
|
|
|
|
|
|
try {
|
2023-04-21 16:31:04 +02:00
|
|
|
const video = await VideoModel.loadFull(videoArg.uuid)
|
2023-04-21 14:55:10 +02:00
|
|
|
const inputVideoFile = video.getMinQualityFile()
|
|
|
|
|
|
|
|
const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video)
|
|
|
|
|
|
|
|
const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async audioInputPath => {
|
|
|
|
const videoOutputPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
|
|
|
|
|
|
|
|
// If the user updates the video preview during transcoding
|
|
|
|
const previewPath = video.getPreview().getPath()
|
|
|
|
const tmpPreviewPath = join(CONFIG.STORAGE.TMP_DIR, basename(previewPath))
|
|
|
|
await copyFile(previewPath, tmpPreviewPath)
|
|
|
|
|
|
|
|
const transcodeOptions = {
|
|
|
|
type: 'merge-audio' as 'merge-audio',
|
|
|
|
|
|
|
|
inputPath: tmpPreviewPath,
|
|
|
|
outputPath: videoOutputPath,
|
|
|
|
|
|
|
|
inputFileMutexReleaser,
|
|
|
|
|
|
|
|
audioPath: audioInputPath,
|
|
|
|
resolution,
|
|
|
|
fps
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
await buildFFmpegVOD(job).transcode(transcodeOptions)
|
|
|
|
|
|
|
|
await remove(audioInputPath)
|
|
|
|
await remove(tmpPreviewPath)
|
|
|
|
} catch (err) {
|
|
|
|
await remove(tmpPreviewPath)
|
|
|
|
throw err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Important to do this before getVideoFilename() to take in account the new file extension
|
|
|
|
inputVideoFile.extname = newExtname
|
|
|
|
inputVideoFile.resolution = resolution
|
2023-07-11 09:21:13 +02:00
|
|
|
inputVideoFile.filename = generateWebVideoFilename(inputVideoFile.resolution, newExtname)
|
2023-04-21 14:55:10 +02:00
|
|
|
|
|
|
|
// ffmpeg generated a new video file, so update the video duration
|
|
|
|
// See https://trac.ffmpeg.org/ticket/5456
|
|
|
|
video.duration = await getVideoStreamDuration(videoOutputPath)
|
|
|
|
await video.save()
|
|
|
|
|
2023-07-11 09:21:13 +02:00
|
|
|
return onWebVideoFileTranscoding({
|
2023-04-21 14:55:10 +02:00
|
|
|
video,
|
|
|
|
videoFile: inputVideoFile,
|
2023-06-01 14:51:16 +02:00
|
|
|
videoOutputPath,
|
|
|
|
wasAudioFile: true
|
2023-04-21 14:55:10 +02:00
|
|
|
})
|
|
|
|
})
|
|
|
|
|
|
|
|
return result
|
|
|
|
} finally {
|
|
|
|
inputFileMutexReleaser()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-11 09:21:13 +02:00
|
|
|
export async function onWebVideoFileTranscoding (options: {
|
2023-04-21 14:55:10 +02:00
|
|
|
video: MVideoFullLight
|
|
|
|
videoFile: MVideoFile
|
|
|
|
videoOutputPath: string
|
2023-06-01 14:51:16 +02:00
|
|
|
wasAudioFile?: boolean // default false
|
2023-04-21 14:55:10 +02:00
|
|
|
}) {
|
2023-06-01 14:51:16 +02:00
|
|
|
const { video, videoFile, videoOutputPath, wasAudioFile } = options
|
2023-04-21 14:55:10 +02:00
|
|
|
|
|
|
|
const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
|
|
|
|
|
|
|
|
try {
|
|
|
|
await video.reload()
|
|
|
|
|
|
|
|
const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
|
|
|
|
|
|
|
|
const stats = await stat(videoOutputPath)
|
|
|
|
|
|
|
|
const probe = await ffprobePromise(videoOutputPath)
|
|
|
|
const fps = await getVideoStreamFPS(videoOutputPath, probe)
|
|
|
|
const metadata = await buildFileMetadata(videoOutputPath, probe)
|
|
|
|
|
|
|
|
await move(videoOutputPath, outputPath, { overwrite: true })
|
|
|
|
|
|
|
|
videoFile.size = stats.size
|
|
|
|
videoFile.fps = fps
|
|
|
|
videoFile.metadata = metadata
|
|
|
|
|
|
|
|
await createTorrentAndSetInfoHash(video, videoFile)
|
|
|
|
|
2023-07-11 09:21:13 +02:00
|
|
|
const oldFile = await VideoFileModel.loadWebVideoFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution })
|
|
|
|
if (oldFile) await video.removeWebVideoFile(oldFile)
|
2023-04-21 14:55:10 +02:00
|
|
|
|
|
|
|
await VideoFileModel.customUpsert(videoFile, 'video', undefined)
|
|
|
|
video.VideoFiles = await video.$get('VideoFiles')
|
|
|
|
|
2023-06-01 14:51:16 +02:00
|
|
|
if (wasAudioFile) {
|
2023-12-27 10:39:09 +01:00
|
|
|
await JobQueue.Instance.createJob(buildStoryboardJobIfNeeded({ video, federate: false }))
|
2023-06-01 14:51:16 +02:00
|
|
|
}
|
|
|
|
|
2023-04-21 14:55:10 +02:00
|
|
|
return { video, videoFile }
|
|
|
|
} finally {
|
|
|
|
mutexReleaser()
|
|
|
|
}
|
|
|
|
}
|