From 272a902b2acbe2795a97fa214ea62001a0c0ccd6 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 19 Oct 2023 14:18:22 +0200 Subject: [PATCH] Optimize video thumbnail generation Process images in worker threads Reduce ffmpeg calls --- packages/ffmpeg/src/ffmpeg-images.ts | 6 +- .../tests/fixtures/thumbnail-playlist.jpg | Bin 5040 -> 5003 bytes packages/tests/fixtures/video_short.mp4.jpg | Bin 5028 -> 5010 bytes packages/tests/fixtures/video_short.ogv.jpg | Bin 5023 -> 4998 bytes packages/tests/fixtures/video_short.webm.jpg | Bin 5028 -> 5003 bytes packages/tests/fixtures/video_short1.webm.jpg | Bin 6231 -> 6298 bytes packages/tests/fixtures/video_short2.webm.jpg | Bin 6607 -> 6626 bytes packages/tests/fixtures/video_short3.webm.jpg | Bin 5674 -> 5640 bytes .../tests/src/api/videos/video-playlists.ts | 2 +- server/core/controllers/api/videos/upload.ts | 51 ++++-- server/core/helpers/image-utils.ts | 55 +------ server/core/initializers/constants.ts | 4 + .../job-queue/handlers/generate-storyboard.ts | 5 +- .../lib/job-queue/handlers/video-import.ts | 66 +++----- .../job-queue/handlers/video-live-ending.ts | 11 +- server/core/lib/thumbnail.ts | 150 +++++++++++++----- server/core/lib/video-file.ts | 5 +- server/core/lib/worker/parent-process.ts | 24 ++- .../core/lib/worker/workers/get-image-size.ts | 3 + 19 files changed, 226 insertions(+), 156 deletions(-) create mode 100644 server/core/lib/worker/workers/get-image-size.ts diff --git a/packages/ffmpeg/src/ffmpeg-images.ts b/packages/ffmpeg/src/ffmpeg-images.ts index 5f7b10345..b6aaa5db5 100644 --- a/packages/ffmpeg/src/ffmpeg-images.ts +++ b/packages/ffmpeg/src/ffmpeg-images.ts @@ -1,3 +1,4 @@ +import { FfprobeData } from 'fluent-ffmpeg' import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' import { getVideoStreamDuration } from './ffprobe.js' @@ -38,10 +39,11 @@ export class FFmpegImage { async generateThumbnailFromVideo (options: { fromPath: string output: string + ffprobe?: FfprobeData }) { - const { fromPath, output } = options + const { fromPath, output, ffprobe } = options - let duration = await getVideoStreamDuration(fromPath) + let duration = await getVideoStreamDuration(fromPath, ffprobe) if (isNaN(duration)) duration = 0 this.commandWrapper.buildCommand(fromPath) diff --git a/packages/tests/fixtures/thumbnail-playlist.jpg b/packages/tests/fixtures/thumbnail-playlist.jpg index 12de5817b90f3dc1f34f7a1e5f6ef9a6bdebc5fc..cc51fc7839a7735d22f8a0c5fda81206eb99d1f6 100644 GIT binary patch delta 3487 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 N9+Z=j3xWe5|Jl+`qZ$AJ delta 3531 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 bKMDXS0dhaOC;&M06ab%vlhF=<0|EcpbuF9m delta 3535 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 KAq4?IAOG3H(6F}v delta 3502 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 diff --git a/packages/tests/fixtures/video_short1.webm.jpg b/packages/tests/fixtures/video_short1.webm.jpg index 6272e00c80e19ed9ffe4439c159e4851a83f8149..13922ca51a70ebba4d0b6a32108d05d1fa2cd39a 100644 GIT binary patch delta 5723 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 yg|=d=AsuL7or$q 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 W#=aS5N;nFFOg5HBOJzpEqGqNKR-Pwe$ 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 delta 4234 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 diff --git a/packages/tests/src/api/videos/video-playlists.ts b/packages/tests/src/api/videos/video-playlists.ts index 18fc21e19..3b0ec0d24 100644 --- a/packages/tests/src/api/videos/video-playlists.ts +++ b/packages/tests/src/api/videos/video-playlists.ts @@ -652,7 +652,7 @@ describe('Test video playlists', function () { let video3: string before(async function () { - this.timeout(60000) + this.timeout(120000) groupUser1 = [ Object.assign({}, servers[0], { accessToken: userTokenServer1 }) ] groupWithoutToken1 = [ Object.assign({}, servers[0], { accessToken: undefined }) ] diff --git a/server/core/controllers/api/videos/upload.ts b/server/core/controllers/api/videos/upload.ts index 2487cfd2e..5de2599fd 100644 --- a/server/core/controllers/api/videos/upload.ts +++ b/server/core/controllers/api/videos/upload.ts @@ -1,4 +1,4 @@ -import express from 'express' +import express, { UploadFiles } from 'express' import { move } from 'fs-extra/esm' import { basename } from 'path' import { getResumableUploadPath } from '@server/helpers/upload.js' @@ -13,9 +13,9 @@ import { buildNextVideoState } from '@server/lib/video-state.js' import { openapiOperationDoc } from '@server/middlewares/doc.js' import { VideoPasswordModel } from '@server/models/video/video-password.js' import { VideoSourceModel } from '@server/models/video/video-source.js' -import { MVideoFile, MVideoFullLight } from '@server/types/models/index.js' +import { MVideoFile, MVideoFullLight, MVideoThumbnail } from '@server/types/models/index.js' import { uuidToShort } from '@peertube/peertube-node-utils' -import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@peertube/peertube-models' +import { HttpStatusCode, ThumbnailType, VideoCreate, VideoPrivacy, VideoState } from '@peertube/peertube-models' import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger.js' import { createReqFiles } from '../../../helpers/express-utils.js' import { logger, loggerTagsFactory } from '../../../helpers/logger.js' @@ -34,8 +34,9 @@ import { } from '../../../middlewares/index.js' import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' import { VideoModel } from '../../../models/video/video.js' -import { getChaptersFromContainer } from '@peertube/peertube-ffmpeg' +import { ffprobePromise, getChaptersFromContainer } from '@peertube/peertube-ffmpeg' import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js' +import { FfprobeData } from 'fluent-ffmpeg' const lTags = loggerTagsFactory('api', 'video') const auditLogger = auditLoggerFactory('videos') @@ -142,12 +143,15 @@ async function addVideo (options: { video.VideoChannel = videoChannel video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object - const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' }) + const ffprobe = await ffprobePromise(videoPhysicalFile.path) + + const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video', ffprobe }) const originalFilename = videoPhysicalFile.originalname const containerChapters = await getChaptersFromContainer({ path: videoPhysicalFile.path, - maxTitleLength: CONSTRAINTS_FIELDS.VIDEO_CHAPTERS.TITLE.max + maxTitleLength: CONSTRAINTS_FIELDS.VIDEO_CHAPTERS.TITLE.max, + ffprobe }) logger.debug(`Got ${containerChapters.length} chapters from video "${video.name}" container`, { containerChapters, ...lTags(video.uuid) }) @@ -158,19 +162,16 @@ async function addVideo (options: { videoPhysicalFile.filename = basename(destination) videoPhysicalFile.path = destination - const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({ - video, - files, - fallback: type => generateLocalVideoMiniature({ video, videoFile, type }) - }) + const thumbnails = await createThumbnailFiles({ video, files, videoFile, ffprobe }) const { videoCreated } = await sequelizeTypescript.transaction(async t => { const sequelizeOptions = { transaction: t } const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight - await videoCreated.addAndSaveThumbnail(thumbnailModel, t) - await videoCreated.addAndSaveThumbnail(previewModel, t) + for (const thumbnail of thumbnails) { + await videoCreated.addAndSaveThumbnail(thumbnail, t) + } // Do not forget to add video channel information to the created video videoCreated.VideoChannel = res.locals.videoChannel @@ -297,3 +298,27 @@ async function deleteUploadResumableCache (req: express.Request, res: express.Re return next() } + +async function createThumbnailFiles (options: { + video: MVideoThumbnail + files: UploadFiles + videoFile: MVideoFile + ffprobe?: FfprobeData +}) { + const { video, videoFile, files, ffprobe } = options + + const models = await buildVideoThumbnailsFromReq({ + video, + files, + fallback: () => Promise.resolve(undefined) + }) + + const filteredModels = models.filter(m => !!m) + + const thumbnailsToGenerate = [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ].filter(type => { + // Generate missing thumbnail types + return !filteredModels.some(m => m.type === type) + }) + + return [ ...filteredModels, ...await generateLocalVideoMiniature({ video, videoFile, types: thumbnailsToGenerate, ffprobe }) ] +} diff --git a/server/core/helpers/image-utils.ts b/server/core/helpers/image-utils.ts index 7c732235d..76dd7946e 100644 --- a/server/core/helpers/image-utils.ts +++ b/server/core/helpers/image-utils.ts @@ -1,20 +1,17 @@ import { copy, remove } from 'fs-extra/esm' import { readFile, rename } from 'fs/promises' -import { join } from 'path' import { ColorActionName } from '@jimp/plugin-color' import { buildUUID, getLowercaseExtension } from '@peertube/peertube-node-utils' -import { convertWebPToJPG, generateThumbnailFromVideo, processGIF } from './ffmpeg/index.js' -import { logger, loggerTagsFactory } from './logger.js' +import { convertWebPToJPG, processGIF } from './ffmpeg/index.js' +import { logger } from './logger.js' import type Jimp from 'jimp' -const lTags = loggerTagsFactory('image-utils') - -function generateImageFilename (extension = '.jpg') { +export function generateImageFilename (extension = '.jpg') { return buildUUID() + extension } -async function processImage (options: { +export async function processImage (options: { path: string destination: string newSize: { width: number, height: number } @@ -38,38 +35,11 @@ async function processImage (options: { } if (keepOriginal !== true) await remove(path) + + logger.debug('Finished processing image %s to %s.', path, destination) } -async function generateImageFromVideoFile (options: { - fromPath: string - folder: string - imageName: string - size: { width: number, height: number } -}) { - const { fromPath, folder, imageName, size } = options - - const pendingImageName = 'pending-' + imageName - const pendingImagePath = join(folder, pendingImageName) - - try { - await generateThumbnailFromVideo({ fromPath, output: pendingImagePath }) - - const destination = join(folder, imageName) - await processImage({ path: pendingImagePath, destination, newSize: size }) - } catch (err) { - logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() }) - - try { - await remove(pendingImagePath) - } catch (err) { - logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() }) - } - - throw err - } -} - -async function getImageSize (path: string) { +export async function getImageSize (path: string) { const inputBuffer = await readFile(path) const Jimp = await import('jimp') @@ -83,16 +53,7 @@ async function getImageSize (path: string) { } // --------------------------------------------------------------------------- - -export { - generateImageFilename, - generateImageFromVideoFile, - - processImage, - - getImageSize -} - +// Private // --------------------------------------------------------------------------- async function jimpProcessor (path: string, destination: string, newSize: { width: number, height: number }, inputExt: string) { diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index a39a5c8ea..9ca5e3e2e 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -971,6 +971,10 @@ const WORKER_THREADS = { PROCESS_IMAGE: { CONCURRENCY: 1, MAX_THREADS: 5 + }, + GET_IMAGE_SIZE: { + CONCURRENCY: 1, + MAX_THREADS: 5 } } diff --git a/server/core/lib/job-queue/handlers/generate-storyboard.ts b/server/core/lib/job-queue/handlers/generate-storyboard.ts index 6f4d00dc5..60ea35f19 100644 --- a/server/core/lib/job-queue/handlers/generate-storyboard.ts +++ b/server/core/lib/job-queue/handlers/generate-storyboard.ts @@ -2,7 +2,7 @@ import { Job } from 'bullmq' import { join } from 'path' import { retryTransactionWrapper } from '@server/helpers/database-utils.js' import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/index.js' -import { generateImageFilename, getImageSize } from '@server/helpers/image-utils.js' +import { generateImageFilename } from '@server/helpers/image-utils.js' import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { deleteFileAndCatch } from '@server/helpers/utils.js' import { CONFIG } from '@server/initializers/config.js' @@ -15,6 +15,7 @@ import { VideoModel } from '@server/models/video/video.js' import { MVideo } from '@server/types/models/index.js' import { FFmpegImage, isAudioFile } from '@peertube/peertube-ffmpeg' import { GenerateStoryboardPayload } from '@peertube/peertube-models' +import { getImageSizeFromWorker } from '@server/lib/worker/parent-process.js' const lTagsBase = loggerTagsFactory('storyboard') @@ -76,7 +77,7 @@ async function processGenerateStoryboard (job: Job): Promise { } }) - const imageSize = await getImageSize(destination) + const imageSize = await getImageSizeFromWorker(destination) await retryTransactionWrapper(() => { return sequelizeTypescript.transaction(async transaction => { diff --git a/server/core/lib/job-queue/handlers/video-import.ts b/server/core/lib/job-queue/handlers/video-import.ts index dfeadc8ef..31b7130f7 100644 --- a/server/core/lib/job-queue/handlers/video-import.ts +++ b/server/core/lib/job-queue/handlers/video-import.ts @@ -26,7 +26,6 @@ import { isAbleToUploadVideo } from '@server/lib/user.js' import { VideoPathManager } from '@server/lib/video-path-manager.js' import { buildNextVideoState } from '@server/lib/video-state.js' import { buildMoveToObjectStorageJob } from '@server/lib/video.js' -import { ThumbnailModel } from '@server/models/video/thumbnail.js' import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js' import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import.js' import { getLowercaseExtension } from '@peertube/peertube-node-utils' @@ -51,6 +50,7 @@ import { Notifier } from '../../notifier/index.js' import { generateLocalVideoMiniature } from '../../thumbnail.js' import { JobQueue } from '../job-queue.js' import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js' +import { FfprobeData } from 'fluent-ffmpeg' async function processVideoImport (job: Job): Promise { const payload = job.data as VideoImportPayload @@ -205,21 +205,11 @@ async function processFile (downloader: () => Promise, videoImport: MVid tempVideoPath = null // This path is not used anymore - let { - miniatureModel: thumbnailModel, - miniatureJSONSave: thumbnailSave - } = await generateMiniature(videoImportWithFiles, videoFile, ThumbnailType.MINIATURE) - - let { - miniatureModel: previewModel, - miniatureJSONSave: previewSave - } = await generateMiniature(videoImportWithFiles, videoFile, ThumbnailType.PREVIEW) + const thumbnails = await generateMiniature({ videoImportWithFiles, videoFile, ffprobe }) // Create torrent await createTorrentAndSetInfoHash(videoImportWithFiles.Video, videoFile) - const videoFileSave = videoFile.toJSON() - const { videoImportUpdated, video } = await retryTransactionWrapper(() => { return sequelizeTypescript.transaction(async t => { // Refresh video @@ -233,8 +223,9 @@ async function processFile (downloader: () => Promise, videoImport: MVid video.state = buildNextVideoState(video.state) await video.save({ transaction: t }) - if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t) - if (previewModel) await video.addAndSaveThumbnail(previewModel, t) + for (const thumbnail of thumbnails) { + await video.addAndSaveThumbnail(thumbnail, t) + } await replaceChaptersIfNotExist({ video, chapters: containerChapters, transaction: t }) @@ -249,14 +240,6 @@ async function processFile (downloader: () => Promise, videoImport: MVid logger.info('Video %s imported.', video.uuid) return { videoImportUpdated, video: videoForFederation } - }).catch(err => { - // Reset fields - if (thumbnailModel) thumbnailModel = new ThumbnailModel(thumbnailSave) - if (previewModel) previewModel = new ThumbnailModel(previewSave) - - videoFile = new VideoFileModel(videoFileSave) - - throw err }) }) @@ -279,34 +262,29 @@ async function refreshVideoImportFromDB (videoImport: MVideoImportDefault, video return Object.assign(videoImport, { Video: videoWithFiles }) } -async function generateMiniature ( - videoImportWithFiles: MVideoImportDefaultFiles, - videoFile: MVideoFile, - thumbnailType: ThumbnailType_Type -) { - // Generate miniature if the import did not created it - const needsMiniature = thumbnailType === ThumbnailType.MINIATURE - ? !videoImportWithFiles.Video.getMiniature() - : !videoImportWithFiles.Video.getPreview() +async function generateMiniature (options: { + videoImportWithFiles: MVideoImportDefaultFiles + videoFile: MVideoFile + ffprobe: FfprobeData +}) { + const { ffprobe, videoFile, videoImportWithFiles } = options - if (!needsMiniature) { - return { - miniatureModel: null, - miniatureJSONSave: null - } + const thumbnailsToGenerate: ThumbnailType_Type[] = [] + + if (!videoImportWithFiles.Video.getMiniature()) { + thumbnailsToGenerate.push(ThumbnailType.MINIATURE) } - const miniatureModel = await generateLocalVideoMiniature({ + if (!videoImportWithFiles.Video.getPreview()) { + thumbnailsToGenerate.push(ThumbnailType.PREVIEW) + } + + return generateLocalVideoMiniature({ video: videoImportWithFiles.Video, videoFile, - type: thumbnailType + types: thumbnailsToGenerate, + ffprobe }) - const miniatureJSONSave = miniatureModel.toJSON() - - return { - miniatureModel, - miniatureJSONSave - } } async function afterImportSuccess (options: { diff --git a/server/core/lib/job-queue/handlers/video-live-ending.ts b/server/core/lib/job-queue/handlers/video-live-ending.ts index dae6515d2..1837f9d33 100644 --- a/server/core/lib/job-queue/handlers/video-live-ending.ts +++ b/server/core/lib/job-queue/handlers/video-live-ending.ts @@ -155,9 +155,14 @@ async function saveReplayToExternalVideo (options: { inputFileMutexReleaser() } - for (const type of [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ]) { - const image = await generateLocalVideoMiniature({ video: replayVideo, videoFile: replayVideo.getMaxQualityFile(), type }) - await replayVideo.addAndSaveThumbnail(image) + const thumbnails = await generateLocalVideoMiniature({ + video: replayVideo, + videoFile: replayVideo.getMaxQualityFile(), + types: [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ] + }) + + for (const thumbnail of thumbnails) { + await replayVideo.addAndSaveThumbnail(thumbnail) } await moveToNextState({ video: replayVideo, isNewVideo: true }) diff --git a/server/core/lib/thumbnail.ts b/server/core/lib/thumbnail.ts index e5424973b..9aeade32f 100644 --- a/server/core/lib/thumbnail.ts +++ b/server/core/lib/thumbnail.ts @@ -1,6 +1,6 @@ import { join } from 'path' import { ThumbnailType, ThumbnailType_Type } from '@peertube/peertube-models' -import { generateImageFilename, generateImageFromVideoFile } from '../helpers/image-utils.js' +import { generateImageFilename } from '../helpers/image-utils.js' import { CONFIG } from '../initializers/config.js' import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants.js' import { ThumbnailModel } from '../models/video/thumbnail.js' @@ -9,6 +9,13 @@ import { MThumbnail } from '../types/models/video/thumbnail.js' import { MVideoPlaylistThumbnail } from '../types/models/video/video-playlist.js' import { VideoPathManager } from './video-path-manager.js' import { downloadImageFromWorker, processImageFromWorker } from './worker/parent-process.js' +import { generateThumbnailFromVideo } from '@server/helpers/ffmpeg/ffmpeg-image.js' +import { logger, loggerTagsFactory } from '@server/helpers/logger.js' +import { remove } from 'fs-extra' +import { FfprobeData } from 'fluent-ffmpeg' +import Bluebird from 'bluebird' + +const lTags = loggerTagsFactory('thumbnail') type ImageSize = { height?: number, width?: number } @@ -88,39 +95,68 @@ function updateLocalVideoMiniatureFromExisting (options: { }) } +// Returns thumbnail models sorted by their size (height) in descendent order (biggest first) function generateLocalVideoMiniature (options: { video: MVideoThumbnail videoFile: MVideoFile - type: ThumbnailType_Type -}) { - const { video, videoFile, type } = options + types: ThumbnailType_Type[] + ffprobe?: FfprobeData +}): Promise { + const { video, videoFile, types, ffprobe } = options + + if (types.length === 0) return Promise.resolve([]) return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), input => { - const { filename, basePath, height, width, existingThumbnail, outputPath } = buildMetadataFromVideo(video, type) - - const thumbnailCreator = videoFile.isAudio() - ? () => processImageFromWorker({ - path: ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND, - destination: outputPath, - newSize: { width, height }, - keepOriginal: true - }) - : () => generateImageFromVideoFile({ - fromPath: input, - folder: basePath, - imageName: filename, - size: { height, width } + // Get bigger images to generate first + const metadatas = types.map(type => buildMetadataFromVideo(video, type)) + .sort((a, b) => { + if (a.height < b.height) return 1 + if (a.height === b.height) return 0 + return -1 }) - return updateThumbnailFromFunction({ - thumbnailCreator, - filename, - height, - width, - type, - automaticallyGenerated: true, - onDisk: true, - existingThumbnail + let biggestImagePath: string + return Bluebird.mapSeries(metadatas, metadata => { + const { filename, basePath, height, width, existingThumbnail, outputPath, type } = metadata + + let thumbnailCreator: () => Promise + + if (videoFile.isAudio()) { + thumbnailCreator = () => processImageFromWorker({ + path: ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND, + destination: outputPath, + newSize: { width, height }, + keepOriginal: true + }) + } else if (biggestImagePath) { + thumbnailCreator = () => processImageFromWorker({ + path: biggestImagePath, + destination: outputPath, + newSize: { width, height }, + keepOriginal: true + }) + } else { + thumbnailCreator = () => generateImageFromVideoFile({ + fromPath: input, + folder: basePath, + imageName: filename, + size: { height, width }, + ffprobe + }) + } + + if (!biggestImagePath) biggestImagePath = outputPath + + return updateThumbnailFromFunction({ + thumbnailCreator, + filename, + height, + width, + type, + automaticallyGenerated: true, + onDisk: true, + existingThumbnail + }) }) }) } @@ -188,22 +224,24 @@ function updateRemoteVideoThumbnail (options: { // --------------------------------------------------------------------------- async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles) { + const thumbnailsToGenerate: ThumbnailType_Type[] = [] + if (video.getMiniature().automaticallyGenerated === true) { - const miniature = await generateLocalVideoMiniature({ - video, - videoFile: video.getMaxQualityFile(), - type: ThumbnailType.MINIATURE - }) - await video.addAndSaveThumbnail(miniature) + thumbnailsToGenerate.push(ThumbnailType.MINIATURE) } if (video.getPreview().automaticallyGenerated === true) { - const preview = await generateLocalVideoMiniature({ - video, - videoFile: video.getMaxQualityFile(), - type: ThumbnailType.PREVIEW - }) - await video.addAndSaveThumbnail(preview) + thumbnailsToGenerate.push(ThumbnailType.PREVIEW) + } + + const models = await generateLocalVideoMiniature({ + video, + videoFile: video.getMaxQualityFile(), + types: thumbnailsToGenerate + }) + + for (const model of models) { + await video.addAndSaveThumbnail(model) } } @@ -256,6 +294,7 @@ function buildMetadataFromVideo (video: MVideoThumbnail, type: ThumbnailType_Typ const basePath = CONFIG.STORAGE.THUMBNAILS_DIR return { + type, filename, basePath, existingThumbnail, @@ -270,6 +309,7 @@ function buildMetadataFromVideo (video: MVideoThumbnail, type: ThumbnailType_Typ const basePath = CONFIG.STORAGE.PREVIEWS_DIR return { + type, filename, basePath, existingThumbnail, @@ -325,3 +365,35 @@ async function updateThumbnailFromFunction (parameters: { return thumbnail } + +async function generateImageFromVideoFile (options: { + fromPath: string + folder: string + imageName: string + size: { width: number, height: number } + ffprobe?: FfprobeData +}) { + const { fromPath, folder, imageName, size, ffprobe } = options + + const pendingImageName = 'pending-' + imageName + const pendingImagePath = join(folder, pendingImageName) + + try { + await generateThumbnailFromVideo({ fromPath, output: pendingImagePath, ffprobe }) + + const destination = join(folder, imageName) + await processImageFromWorker({ path: pendingImagePath, destination, newSize: size }) + + return destination + } catch (err) { + logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() }) + + try { + await remove(pendingImagePath) + } catch (err) { + logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() }) + } + + throw err + } +} diff --git a/server/core/lib/video-file.ts b/server/core/lib/video-file.ts index ccc5668a2..326dff75e 100644 --- a/server/core/lib/video-file.ts +++ b/server/core/lib/video-file.ts @@ -13,10 +13,11 @@ import { MIMETYPES } from '@server/initializers/constants.js' async function buildNewFile (options: { path: string mode: 'web-video' | 'hls' + ffprobe?: FfprobeData }) { - const { path, mode } = options + const { path, mode, ffprobe: probeArg } = options - const probe = await ffprobePromise(path) + const probe = probeArg ?? await ffprobePromise(path) const size = await getFileSize(path) const videoFile = new VideoFileModel({ diff --git a/server/core/lib/worker/parent-process.ts b/server/core/lib/worker/parent-process.ts index 2b56b4526..d59726905 100644 --- a/server/core/lib/worker/parent-process.ts +++ b/server/core/lib/worker/parent-process.ts @@ -1,9 +1,10 @@ import { join } from 'path' import Piscina from 'piscina' import { JOB_CONCURRENCY, WORKER_THREADS } from '@server/initializers/constants.js' -import httpBroadcast from './workers/http-broadcast.js' -import downloadImage from './workers/image-downloader.js' -import processImage from './workers/image-processor.js' +import type httpBroadcast from './workers/http-broadcast.js' +import type downloadImage from './workers/image-downloader.js' +import type processImage from './workers/image-processor.js' +import type getImageSize from './workers/get-image-size.js' let downloadImageWorker: Piscina @@ -37,6 +38,22 @@ function processImageFromWorker (options: Parameters[0]): P // --------------------------------------------------------------------------- +let getImageSizeWorker: Piscina + +function getImageSizeFromWorker (options: Parameters[0]): Promise> { + if (!getImageSizeWorker) { + getImageSizeWorker = new Piscina({ + filename: new URL(join('workers', 'get-image-size.js'), import.meta.url).href, + concurrentTasksPerWorker: WORKER_THREADS.GET_IMAGE_SIZE.CONCURRENCY, + maxThreads: WORKER_THREADS.GET_IMAGE_SIZE.MAX_THREADS + }) + } + + return getImageSizeWorker.run(options) +} + +// --------------------------------------------------------------------------- + let parallelHTTPBroadcastWorker: Piscina function parallelHTTPBroadcastFromWorker (options: Parameters[0]): Promise> { @@ -73,5 +90,6 @@ export { downloadImageFromWorker, processImageFromWorker, parallelHTTPBroadcastFromWorker, + getImageSizeFromWorker, sequentialHTTPBroadcastFromWorker } diff --git a/server/core/lib/worker/workers/get-image-size.ts b/server/core/lib/worker/workers/get-image-size.ts new file mode 100644 index 000000000..0d3634308 --- /dev/null +++ b/server/core/lib/worker/workers/get-image-size.ts @@ -0,0 +1,3 @@ +import { getImageSize } from '@server/helpers/image-utils.js' + +export default getImageSize