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 12de5817b..cc51fc783 100644 Binary files a/packages/tests/fixtures/thumbnail-playlist.jpg and b/packages/tests/fixtures/thumbnail-playlist.jpg differ diff --git a/packages/tests/fixtures/video_short.mp4.jpg b/packages/tests/fixtures/video_short.mp4.jpg index 7ac29122c..2ff014439 100644 Binary files a/packages/tests/fixtures/video_short.mp4.jpg and b/packages/tests/fixtures/video_short.mp4.jpg differ diff --git a/packages/tests/fixtures/video_short.ogv.jpg b/packages/tests/fixtures/video_short.ogv.jpg index 5bc63969b..4fcb8fbd1 100644 Binary files a/packages/tests/fixtures/video_short.ogv.jpg and b/packages/tests/fixtures/video_short.ogv.jpg differ diff --git a/packages/tests/fixtures/video_short.webm.jpg b/packages/tests/fixtures/video_short.webm.jpg index 7ac29122c..cc51fc783 100644 Binary files a/packages/tests/fixtures/video_short.webm.jpg and b/packages/tests/fixtures/video_short.webm.jpg differ diff --git a/packages/tests/fixtures/video_short1.webm.jpg b/packages/tests/fixtures/video_short1.webm.jpg index 6272e00c8..13922ca51 100644 Binary files a/packages/tests/fixtures/video_short1.webm.jpg and b/packages/tests/fixtures/video_short1.webm.jpg differ diff --git a/packages/tests/fixtures/video_short2.webm.jpg b/packages/tests/fixtures/video_short2.webm.jpg index afe476c7f..1920f2348 100644 Binary files a/packages/tests/fixtures/video_short2.webm.jpg and b/packages/tests/fixtures/video_short2.webm.jpg differ diff --git a/packages/tests/fixtures/video_short3.webm.jpg b/packages/tests/fixtures/video_short3.webm.jpg index b572f676e..857b3d1c1 100644 Binary files a/packages/tests/fixtures/video_short3.webm.jpg and b/packages/tests/fixtures/video_short3.webm.jpg differ 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