From d17972b2f18b09a7ea7fad301387c14ab0cb1e55 Mon Sep 17 00:00:00 2001 From: Romaric Mourgues Date: Mon, 12 Jun 2023 11:09:36 +0200 Subject: [PATCH] =?UTF-8?q?=E2=AD=90=EF=B8=8F=20Put=20back=20preview=20ser?= =?UTF-8?q?vice=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Put back the preview service * Fix imports linter --- .../node/src/services/files/services/index.ts | 79 +++++++++ .../src/services/files/services/preview.ts | 67 +++++++ .../backend/node/src/services/files/types.ts | 1 + .../services/files/web/controllers/files.ts | 1 + .../node/src/services/global-resolver.ts | 49 +++-- .../node/src/services/previews/index.ts | 16 ++ .../previews/services/files/engine/clear.ts | 48 +++++ .../previews/services/files/engine/index.ts | 15 ++ .../previews/services/files/engine/service.ts | 118 +++++++++++++ .../services/files/processing/image.ts | 54 ++++++ .../services/files/processing/office.ts | 29 +++ .../previews/services/files/processing/pdf.ts | 43 +++++ .../services/files/processing/service.ts | 71 ++++++++ .../services/files/processing/video.ts | 167 ++++++++++++++++++ .../node/src/services/previews/types.ts | 61 +++++++ .../node/src/services/previews/utils.ts | 11 ++ 16 files changed, 811 insertions(+), 19 deletions(-) create mode 100644 tdrive/backend/node/src/services/files/services/preview.ts create mode 100644 tdrive/backend/node/src/services/previews/index.ts create mode 100644 tdrive/backend/node/src/services/previews/services/files/engine/clear.ts create mode 100644 tdrive/backend/node/src/services/previews/services/files/engine/index.ts create mode 100644 tdrive/backend/node/src/services/previews/services/files/engine/service.ts create mode 100644 tdrive/backend/node/src/services/previews/services/files/processing/image.ts create mode 100644 tdrive/backend/node/src/services/previews/services/files/processing/office.ts create mode 100644 tdrive/backend/node/src/services/previews/services/files/processing/pdf.ts create mode 100644 tdrive/backend/node/src/services/previews/services/files/processing/service.ts create mode 100644 tdrive/backend/node/src/services/previews/services/files/processing/video.ts create mode 100644 tdrive/backend/node/src/services/previews/types.ts create mode 100644 tdrive/backend/node/src/services/previews/utils.ts diff --git a/tdrive/backend/node/src/services/files/services/index.ts b/tdrive/backend/node/src/services/files/services/index.ts index 0f6fe021..3081211f 100644 --- a/tdrive/backend/node/src/services/files/services/index.ts +++ b/tdrive/backend/node/src/services/files/services/index.ts @@ -13,6 +13,12 @@ import { ExecutionContext, } from "../../../core/platform/framework/api/crud-service"; import gr from "../../global-resolver"; +import { + PreviewClearMessageQueueRequest, + PreviewMessageQueueRequest, +} from "../../../services/previews/types"; +import { PreviewFinishedProcessor } from "./preview"; +import _ from "lodash"; export class FileServiceImpl { version: "1"; @@ -23,6 +29,9 @@ export class FileServiceImpl { async init(): Promise { try { await Promise.all([(this.repository = await gr.database.getRepository("files", File))]); + gr.platformServices.messageQueue.processor.addHandler( + new PreviewFinishedProcessor(this, this.repository), + ); } catch (err) { logger.error("Error while initializing files service", err); } @@ -106,6 +115,60 @@ export class FileServiceImpl { if (entity.upload_data.chunks === 1 && totalUploadedSize) { entity.upload_data.size = totalUploadedSize; await this.repository.save(entity, context); + + /** Send preview generation task */ + if (entity.upload_data.size < this.max_preview_file_size) { + const document: PreviewMessageQueueRequest["document"] = { + id: JSON.stringify(_.pick(entity, "id", "company_id")), + provider: gr.platformServices.storage.getConnectorType(), + + path: getFilePath(entity), + encryption_algo: this.algorithm, + encryption_key: entity.encryption_key, + chunks: entity.upload_data.chunks, + + filename: entity.metadata.name, + mime: entity.metadata.mime, + }; + const output = { + provider: gr.platformServices.storage.getConnectorType(), + path: `${getFilePath(entity)}/thumbnails/`, + encryption_algo: this.algorithm, + encryption_key: entity.encryption_key, + pages: 10, + }; + + entity.metadata.thumbnails_status = "waiting"; + await this.repository.save(entity, context); + + if (!options?.ignoreThumbnails) { + try { + await gr.platformServices.messageQueue.publish( + "services:preview", + { + data: { document, output }, + }, + ); + + if (options.waitForThumbnail) { + entity = await gr.services.files.getFile( + { + id: entity.id, + company_id: context.company.id, + }, + context, + { waitForThumbnail: true }, + ); + } + } catch (err) { + entity.metadata.thumbnails_status = "error"; + await this.repository.save(entity, context); + + logger.warn({ err }, "Previewing - Error while sending "); + } + } + } + /** End preview generation task generation */ } } @@ -227,6 +290,22 @@ export class FileServiceImpl { totalChunks: fileToDelete.upload_data.chunks, }); + if (fileToDelete.thumbnails.length > 0) { + await gr.platformServices.messageQueue.publish( + "services:preview:clear", + { + data: { + document: { + id: JSON.stringify(_.pick(fileToDelete, "id", "company_id")), + provider: gr.platformServices.storage.getConnectorType(), + path: `${path}/thumbnails/`, + thumbnails_number: fileToDelete.thumbnails.length, + }, + }, + }, + ); + } + return new DeleteResult("files", fileToDelete, true); } } diff --git a/tdrive/backend/node/src/services/files/services/preview.ts b/tdrive/backend/node/src/services/files/services/preview.ts new file mode 100644 index 00000000..9f3d2ba7 --- /dev/null +++ b/tdrive/backend/node/src/services/files/services/preview.ts @@ -0,0 +1,67 @@ +import { logger, TdriveContext } from "../../../core/platform/framework"; +import { PreviewMessageQueueCallback } from "../../../../src/services/previews/types"; +import Repository from "../../../../src/core/platform/services/database/services/orm/repository/repository"; +import { File } from "../entities/file"; +import { FileServiceImpl } from "./index"; +import { MessageQueueHandler } from "../../../core/platform/services/message-queue/api"; +import { ExecutionContext } from "../../../core/platform/framework/api/crud-service"; + +/** + * Update the file metadata and upload the thumbnails in storage + */ +export class PreviewFinishedProcessor + implements MessageQueueHandler +{ + constructor(readonly service: FileServiceImpl, private repository: Repository) {} + + async init(_context?: TdriveContext): Promise { + return this; + } + + readonly topics = { + in: "services:preview:callback", + }; + + readonly options = { + unique: true, + ack: true, + }; + + name = "FilePreviewProcessor"; + + validate(message: PreviewMessageQueueCallback): boolean { + return !!(message && message.document); + } + + async process(message: PreviewMessageQueueCallback, context?: ExecutionContext): Promise { + logger.info( + `${this.name} - Updating file metadata with preview generation ${message.thumbnails.length}`, + ); + + const pk: { company_id: string; id: string } = JSON.parse(message.document.id); + const entity = await this.repository.findOne(pk, {}, context); + + if (!entity) { + logger.info(`This file ${message.document.id} does not exists anymore.`); + return; + } + + entity.thumbnails = (message.thumbnails || []).map((thumb, index) => { + return { + index, + id: thumb.path.split("/").pop(), + size: thumb.size, + type: thumb.type, + width: thumb.width, + height: thumb.height, + url: `/internal/services/files/v1/companies/${entity.company_id}/files/${entity.id}/thumbnails/${index}`, + }; + }); + + if (!entity.metadata) entity.metadata = {}; + entity.metadata.thumbnails_status = "done"; + + await this.repository.save(entity, context); + return "done"; + } +} diff --git a/tdrive/backend/node/src/services/files/types.ts b/tdrive/backend/node/src/services/files/types.ts index b9190e13..12b5759f 100644 --- a/tdrive/backend/node/src/services/files/types.ts +++ b/tdrive/backend/node/src/services/files/types.ts @@ -5,4 +5,5 @@ export type UploadOptions = { totalChunks: number; chunkNumber: number; waitForThumbnail: boolean; + ignoreThumbnails: boolean; }; diff --git a/tdrive/backend/node/src/services/files/web/controllers/files.ts b/tdrive/backend/node/src/services/files/web/controllers/files.ts index 8098dff6..dab7f21c 100644 --- a/tdrive/backend/node/src/services/files/web/controllers/files.ts +++ b/tdrive/backend/node/src/services/files/web/controllers/files.ts @@ -28,6 +28,7 @@ export class FileController { filename: q.resumableFilename || q.filename || file?.filename || undefined, type: q.resumableType || q.type || file?.mimetype || undefined, waitForThumbnail: q.thumbnail_sync, + ignoreThumbnails: q.ignore_thumbnails, }; const id = request.params.id; diff --git a/tdrive/backend/node/src/services/global-resolver.ts b/tdrive/backend/node/src/services/global-resolver.ts index 16d2f206..4c9024b9 100644 --- a/tdrive/backend/node/src/services/global-resolver.ts +++ b/tdrive/backend/node/src/services/global-resolver.ts @@ -2,36 +2,39 @@ import { FastifyInstance } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { TdrivePlatform } from "../core/platform/platform"; +import AuthServiceAPI from "../core/platform/services/auth/provider"; +import { CounterAPI } from "../core/platform/services/counter/types"; +import { CronAPI } from "../core/platform/services/cron/api"; +import { DatabaseServiceAPI } from "../core/platform/services/database/api"; +import EmailPusherAPI from "../core/platform/services/email-pusher/provider"; +import { MessageQueueServiceAPI } from "../core/platform/services/message-queue/api"; +import { PushServiceAPI } from "../core/platform/services/push/api"; import { RealtimeServiceAPI } from "../core/platform/services/realtime/api"; -import WebServerAPI from "../core/platform/services/webserver/provider"; import { SearchServiceAPI } from "../core/platform/services/search/api"; import StorageAPI from "../core/platform/services/storage/provider"; -import { MessageQueueServiceAPI } from "../core/platform/services/message-queue/api"; -import { CounterAPI } from "../core/platform/services/counter/types"; -import { DatabaseServiceAPI } from "../core/platform/services/database/api"; -import AuthServiceAPI from "../core/platform/services/auth/provider"; -import { PushServiceAPI } from "../core/platform/services/push/api"; -import { CronAPI } from "../core/platform/services/cron/api"; -import WebSocketAPI from "../core/platform/services/websocket/provider"; import TrackerAPI from "../core/platform/services/tracker/provider"; -import EmailPusherAPI from "../core/platform/services/email-pusher/provider"; +import WebServerAPI from "../core/platform/services/webserver/provider"; +import WebSocketAPI from "../core/platform/services/websocket/provider"; -import { logger } from "../core/platform/framework"; import assert from "assert"; -import { CompanyServiceImpl } from "./user/services/companies"; -import { WorkspaceServiceImpl } from "./workspaces/services/workspace"; -import { UserExternalLinksServiceImpl } from "./user/services/external_links"; -import { UserServiceImpl } from "./user/services/users/service"; -import { CompanyApplicationServiceImpl } from "./applications/services/company-applications"; +import { logger } from "../core/platform/framework"; import { ApplicationServiceImpl } from "./applications/services/applications"; -import { FileServiceImpl } from "./files/services"; -import { ConsoleServiceImpl } from "./console/service"; -import { StatisticsServiceImpl } from "./statistics/service"; +import { CompanyApplicationServiceImpl } from "./applications/services/company-applications"; import { ApplicationHooksService } from "./applications/services/hooks"; -import OnlineServiceImpl from "./online/service"; +import { ConsoleServiceImpl } from "./console/service"; import { DocumentsService } from "./documents/services"; import { DocumentsEngine } from "./documents/services/engine"; +import { FileServiceImpl } from "./files/services"; +import OnlineServiceImpl from "./online/service"; +import { PreviewProcessService } from "./previews/services/files/processing/service"; +import { StatisticsServiceImpl } from "./statistics/service"; import { TagsService } from "./tags/services/tags"; +import { CompanyServiceImpl } from "./user/services/companies"; +import { UserExternalLinksServiceImpl } from "./user/services/external_links"; +import { UserServiceImpl } from "./user/services/users/service"; +import { WorkspaceServiceImpl } from "./workspaces/services/workspace"; + +import { PreviewEngine } from "./previews/services/files/engine"; type PlatformServices = { auth: AuthServiceAPI; @@ -55,6 +58,9 @@ type TdriveServices = { console: ConsoleServiceImpl; statistics: StatisticsServiceImpl; externalUser: UserExternalLinksServiceImpl; + preview: { + files: PreviewProcessService; + }; applications: { marketplaceApps: ApplicationServiceImpl; companyApps: CompanyApplicationServiceImpl; @@ -106,6 +112,8 @@ class GlobalResolver { assert(service, `Platform service ${key} was not initialized`); }); + await new PreviewEngine().init(); + this.services = { workspaces: await new WorkspaceServiceImpl().init(), companies: await new CompanyServiceImpl().init(), @@ -113,6 +121,9 @@ class GlobalResolver { console: await new ConsoleServiceImpl().init(), statistics: await new StatisticsServiceImpl().init(), externalUser: await new UserExternalLinksServiceImpl().init(), + preview: { + files: await new PreviewProcessService().init(), + }, applications: { marketplaceApps: await new ApplicationServiceImpl().init(), companyApps: await new CompanyApplicationServiceImpl().init(), diff --git a/tdrive/backend/node/src/services/previews/index.ts b/tdrive/backend/node/src/services/previews/index.ts new file mode 100644 index 00000000..8008de0f --- /dev/null +++ b/tdrive/backend/node/src/services/previews/index.ts @@ -0,0 +1,16 @@ +import { Prefix, TdriveService } from "../../core/platform/framework"; + +@Prefix("/internal/services/previews/v1") +export default class PreviewsService extends TdriveService { + version = "1"; + name = "previews"; + + public async doInit(): Promise { + return this; + } + + // TODO: remove + api(): undefined { + return undefined; + } +} diff --git a/tdrive/backend/node/src/services/previews/services/files/engine/clear.ts b/tdrive/backend/node/src/services/previews/services/files/engine/clear.ts new file mode 100644 index 00000000..06749c49 --- /dev/null +++ b/tdrive/backend/node/src/services/previews/services/files/engine/clear.ts @@ -0,0 +1,48 @@ +import { logger, TwakeContext } from "../../../../../core/platform/framework"; +import { PreviewClearMessageQueueRequest, PreviewMessageQueueCallback } from "../../../types"; +import gr from "../../../../global-resolver"; +import { MessageQueueHandler } from "../../../../../core/platform/services/message-queue/api"; + +/** + * Clear thumbnails when the delete task is called + */ +export class ClearProcessor + implements MessageQueueHandler +{ + readonly name = "ClearProcessor"; + + readonly topics = { + in: "services:preview:clear", + out: "services:preview:callback", + }; + + readonly options = { + unique: true, + ack: true, + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + init?(context?: TwakeContext): Promise { + throw new Error("Method not implemented."); + } + + validate(message: PreviewClearMessageQueueRequest): boolean { + return !!(message && message.document); + } + + async process(message: PreviewClearMessageQueueRequest): Promise { + logger.info(`${this.name} - Processing preview generation ${message.document.id}`); + + if (!this.validate(message)) { + throw new Error("Missing required fields"); + } + + for (let i = 0; i < message.document.thumbnails_number; i++) { + await gr.platformServices.storage.remove( + `${message.document.path.replace(/\/$/, "")}/${i}.png`, + ); + } + + return { document: message.document, thumbnails: [] }; + } +} diff --git a/tdrive/backend/node/src/services/previews/services/files/engine/index.ts b/tdrive/backend/node/src/services/previews/services/files/engine/index.ts new file mode 100644 index 00000000..0d04a2b5 --- /dev/null +++ b/tdrive/backend/node/src/services/previews/services/files/engine/index.ts @@ -0,0 +1,15 @@ +import { Initializable } from "../../../../../core/platform/framework"; +import { ClearProcessor } from "./clear"; +import { PreviewProcessor } from "./service"; +import gr from "../../../../global-resolver"; + +/** + * The notification engine is in charge of processing data and delivering user notifications on the right place + */ +export class PreviewEngine implements Initializable { + async init(): Promise { + gr.platformServices.messageQueue.processor.addHandler(new PreviewProcessor()); + gr.platformServices.messageQueue.processor.addHandler(new ClearProcessor()); + return this; + } +} diff --git a/tdrive/backend/node/src/services/previews/services/files/engine/service.ts b/tdrive/backend/node/src/services/previews/services/files/engine/service.ts new file mode 100644 index 00000000..46f85930 --- /dev/null +++ b/tdrive/backend/node/src/services/previews/services/files/engine/service.ts @@ -0,0 +1,118 @@ +import fs, { promises as fsPromise } from "fs"; +import { logger } from "../../../../../core/platform/framework"; +import { + PreviewMessageQueueCallback, + PreviewMessageQueueRequest, + ThumbnailResult, +} from "../../../types"; +import gr from "../../../../global-resolver"; +import { MessageQueueHandler } from "../../../../../core/platform/services/message-queue/api"; +import { getTmpFile } from "../../../../../utils/files"; + +const { unlink } = fsPromise; +/** + * Generate thumbnails when the upload is finished + */ +export class PreviewProcessor + implements MessageQueueHandler +{ + readonly name = "PreviewProcessor"; + + init?(): Promise { + throw new Error("Method not implemented."); + } + + readonly topics = { + in: "services:preview", + out: "services:preview:callback", + }; + + readonly options = { + unique: true, + ack: true, + }; + + validate(message: PreviewMessageQueueRequest): boolean { + return !!(message && message.document && message.output); + } + + async process(message: PreviewMessageQueueRequest): Promise { + logger.info(`${this.name} - Processing preview generation ${message.document.id}`); + + let res: PreviewMessageQueueCallback = { document: message.document, thumbnails: [] }; + try { + res = await this.generate(message); + } catch (err) { + logger.error(`${this.name} - Can't generate thumbnails ${err}`); + } + + logger.info( + `${this.name} - Generated ${res.thumbnails.length} thumbnails from ${ + message.document.filename || message.document.id + }`, + ); + + return res; + } + + async generate(message: PreviewMessageQueueRequest): Promise { + //Download original file + const readable = await gr.platformServices.storage.read(message.document.path, { + totalChunks: message.document.chunks, + encryptionAlgo: message.document.encryption_algo, + encryptionKey: message.document.encryption_key, + }); + if (!readable) { + return { document: message.document, thumbnails: [] }; + } + + const inputPath = getTmpFile(); + const writable = fs.createWriteStream(inputPath); + + readable.pipe(writable); + + await new Promise(r => { + writable.on("finish", r); + }); + + writable.end(); + + //Generate previews + let localThumbnails: ThumbnailResult[] = []; + + try { + localThumbnails = await gr.services.preview.files.generateThumbnails( + { path: inputPath, mime: message.document.mime, filename: message.document.filename }, + message.output, + ); + } catch (err) { + logger.error(`${this.name} - Can't generate thumbnails ${err}`); + localThumbnails = []; + throw Error("Can't generate thumbnails."); + } + + const thumbnails: PreviewMessageQueueCallback["thumbnails"] = []; + + for (let i = 0; i < localThumbnails.length; i++) { + const uploadThumbnailPath = `${message.output.path.replace(/\/$/, "")}/${i}.png`; + const uploadThumbnail = fs.createReadStream(localThumbnails[i].path); + + await gr.platformServices.storage.write(uploadThumbnailPath, uploadThumbnail, { + encryptionAlgo: message.output.encryption_algo, + encryptionKey: message.output.encryption_key, + }); + + thumbnails.push({ + path: uploadThumbnailPath, + size: localThumbnails[i].size, + type: localThumbnails[i].type, + width: localThumbnails[i].width, + height: localThumbnails[i].height, + }); + + await unlink(localThumbnails[i].path); + } + + return { document: message.document, thumbnails }; + } +} diff --git a/tdrive/backend/node/src/services/previews/services/files/processing/image.ts b/tdrive/backend/node/src/services/previews/services/files/processing/image.ts new file mode 100644 index 00000000..349f5b22 --- /dev/null +++ b/tdrive/backend/node/src/services/previews/services/files/processing/image.ts @@ -0,0 +1,54 @@ +import sharp from "sharp"; +import { cleanFiles, getTmpFile } from "../../../../../utils/files"; +import { PreviewMessageQueueRequest, ThumbnailResult } from "../../../types"; +import { logger } from "../../../../../core/platform/framework/logger"; + +export async function generatePreview( + inputPaths: string[], + options: PreviewMessageQueueRequest["output"], +): Promise<{ + output: ThumbnailResult[]; + done: boolean; + error?: string; +}> { + const output: ThumbnailResult[] = []; + + for (const inputPath of inputPaths) { + let result: sharp.OutputInfo; + const outputPath = getTmpFile(); + try { + const inputMetadata = await sharp(inputPath).metadata(); + const outputFormat = computeNewFormat(inputMetadata, options); + + result = await sharp(inputPath).rotate().resize(outputFormat).toFile(outputPath); + output.push({ + path: outputPath, + width: result.width, + height: result.height, + type: "image/png", + size: result.size, + }); + } catch (error) { + logger.info(`sharp cant process ${error}`); + cleanFiles([outputPath]); + throw Error("Can't resize thumnail with Sharp"); + } + } + + return { + output, + done: true, + }; +} + +function computeNewFormat( + inputMetadata: sharp.Metadata, + options?: PreviewMessageQueueRequest["output"], +): { width: number; height: number } { + const maxOutputWidth = options?.width || 600; + const maxOutputHeight = options?.height || 400; + const inputWidth = inputMetadata.width; + const inputHeight = inputMetadata.height; + const scale = Math.max(inputWidth / maxOutputWidth, inputHeight / maxOutputHeight); + return { width: Math.round(inputWidth / scale), height: Math.round(inputHeight / scale) }; +} diff --git a/tdrive/backend/node/src/services/previews/services/files/processing/office.ts b/tdrive/backend/node/src/services/previews/services/files/processing/office.ts new file mode 100644 index 00000000..2cd99a3c --- /dev/null +++ b/tdrive/backend/node/src/services/previews/services/files/processing/office.ts @@ -0,0 +1,29 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const unoconv = require("unoconv-promise"); +import { cleanFiles } from "../../../../../utils/files"; +import { logger } from "../../../../../core/platform/framework/logger"; + +export async function convertFromOffice( + path: string, + options: { + numberOfPages?: number; + }, +): Promise<{ output: string; done: boolean }> { + if (options.numberOfPages >= 1) { + const outputPath = `${path}.pdf`; + try { + await unoconv.run({ + file: path, + output: outputPath, + export: `PageRange=1-${options.numberOfPages}`, + }); + } catch (err) { + logger.error(`unoconv cant process ${err}`); + cleanFiles([outputPath]); + throw Error("Can't convert file with unoconv"); + } + return { output: outputPath, done: true }; + } else { + logger.error("Unoconv can't processe, number of pages lower than 1"); + } +} diff --git a/tdrive/backend/node/src/services/previews/services/files/processing/pdf.ts b/tdrive/backend/node/src/services/previews/services/files/processing/pdf.ts new file mode 100644 index 00000000..4ce052af --- /dev/null +++ b/tdrive/backend/node/src/services/previews/services/files/processing/pdf.ts @@ -0,0 +1,43 @@ +import { fromPath } from "pdf2pic"; +import { mkdirSync } from "fs"; +import { cleanFiles, getTmpFile } from "../../../../../utils/files"; + +export async function convertFromPdf( + inputPath: string, + options: { + numberOfPages: number; + }, +): Promise<{ output: string[]; done: boolean }> { + const pages: string[] = []; + + try { + const pdfOptions = { + density: 100, + saveFilename: "output", + savePath: getTmpFile(), + format: "png", + }; + mkdirSync(pdfOptions.savePath, { recursive: true }); + const storeAsImage = fromPath(inputPath, pdfOptions); + try { + for (let i = 1; i <= options.numberOfPages; i++) { + const image = await storeAsImage(i); + pages.push( + `${pdfOptions.savePath}/${pdfOptions.saveFilename}.${image.page}.${pdfOptions.format}`, + ); + } + } catch (err) { + if (!pages.length) { + throw err; + } + //Just no more page to convert + } + } catch (error) { + console.error(error); + for (const file of pages) { + cleanFiles([file]); + } + throw Error("Can't convert file with pdf-image."); + } + return { output: pages, done: true }; +} diff --git a/tdrive/backend/node/src/services/previews/services/files/processing/service.ts b/tdrive/backend/node/src/services/previews/services/files/processing/service.ts new file mode 100644 index 00000000..139a5316 --- /dev/null +++ b/tdrive/backend/node/src/services/previews/services/files/processing/service.ts @@ -0,0 +1,71 @@ +import { generatePreview as thumbnailsFromImages } from "./image"; +import { convertFromOffice } from "./office"; +import { convertFromPdf } from "./pdf"; +import { isFileType } from "../../../utils"; +import { + imageExtensions, + officeExtensions, + pdfExtensions, + videoExtensions, +} from "../../../../../utils/mime"; +import { PreviewMessageQueueRequest, ThumbnailResult } from "../../../types"; +import { generateVideoPreview } from "./video"; +import { Initializable, TwakeServiceProvider } from "../../../../../core/platform/framework"; +import { cleanFiles } from "../../../../../utils/files"; + +export class PreviewProcessService implements TwakeServiceProvider, Initializable { + name: "PreviewProcessService"; + version: "1"; + + async init(): Promise { + return this; + } + + async generateThumbnails( + document: Pick, + options: PreviewMessageQueueRequest["output"], + ): Promise { + if (isFileType(document.mime, document.filename, officeExtensions)) { + const pdfPath = await convertFromOffice(document.path, { + numberOfPages: options.pages, + }); + await cleanFiles([document.path]); + const thumbnailPath = await convertFromPdf(pdfPath.output, { + numberOfPages: options.pages, + }); + await cleanFiles([pdfPath.output]); + const images = (await thumbnailsFromImages(thumbnailPath.output, options)).output; + await cleanFiles(thumbnailPath.output); + return images; + } + + if (isFileType(document.mime, document.filename, pdfExtensions)) { + const thumbnailPath = await convertFromPdf(document.path, { + numberOfPages: options.pages, + }); + await cleanFiles([document.path]); + const images = (await thumbnailsFromImages(thumbnailPath.output, options)).output; + await cleanFiles(thumbnailPath.output); + return images; + } + + if (isFileType(document.mime, document.filename, imageExtensions)) { + const images = (await thumbnailsFromImages([document.path], options)).output; + await cleanFiles([document.path]); + return images; + } + + if (isFileType(document.mime, document.filename, videoExtensions)) { + try { + const images = await generateVideoPreview([document.path]); + await cleanFiles([document.path]); + + return images; + } catch (error) { + throw Error("failed to generate video preview"); + } + } + + throw "Can not proccess, file type can't be defined"; + } +} diff --git a/tdrive/backend/node/src/services/previews/services/files/processing/video.ts b/tdrive/backend/node/src/services/previews/services/files/processing/video.ts new file mode 100644 index 00000000..e64041dc --- /dev/null +++ b/tdrive/backend/node/src/services/previews/services/files/processing/video.ts @@ -0,0 +1,167 @@ +import ffmpeg from "fluent-ffmpeg"; +import { temporaryThumbnailFile, ThumbnailResult } from "../../../types"; +import fs from "fs"; +import { cleanFiles, getTmpFile } from "../../../../../utils/files"; + +/** + * Generate thumbnails for given video files. + * + * @param {String[]} videoPaths - the input video paths + * @returns {Promise} - resolves when the thumbnails are generated + */ +export async function generateVideoPreview(videoPaths: string[]): Promise { + const output: ThumbnailResult[] = []; + + for (const videoPath of videoPaths) { + const { fileName, folder, filePath } = getTemporaryThumbnailFile(); + + try { + const { width, height } = await takeVideoScreenshot(videoPath, folder, fileName); + + output.push({ + ...getThumbnailInformation(filePath), + width, + height, + }); + } catch (error) { + cleanFiles([filePath]); + throw Error(`failed to generate video preview: ${error}`); + } + } + + return output; +} + +/** + * Generate one thumbnail from a single video file. + * the thumbnail is the first frame from the video + * + * @param {String} inputPath - the input video Path + * @param {String} outputFolder - the output folder + * @param {String} outputFile - the output file name + * @returns {Primise} - resolves when the thumbnail is generated + */ +const takeVideoScreenshot = async ( + inputPath: string, + outputFolder: string, + outputFile: string, +): Promise<{ width: number; height: number }> => { + return new Promise(async (resolve, reject) => { + try { + const { width, height } = await getVideoDimensions(inputPath); + const { width: outputWidth, height: outputHeight } = calculateThumbnailDimensions( + width, + height, + ); + + ffmpeg(inputPath) + .screenshot({ + count: 1, + filename: outputFile, + folder: outputFolder, + timemarks: ["0"], + size: `${outputWidth}x${outputHeight}`, + }) + .on("end", () => { + resolve({ width: outputWidth, height: outputHeight }); + }) + .on("error", error => { + reject(error); + }); + } catch (error) { + reject(error); + } + }); +}; + +/** + * Get the generated thumbnail information. + * + * @param {String} path - the path to the thumbnail + * @param {PreviewMessageQueueRequest["output"]} options - the options for the thumbnails + * @returns { { path: string, type: string, size: number } } - the thumbnail information + */ +const getThumbnailInformation = ( + path: string, +): { + path: string; + type: string; + size: number; +} => { + const stats = fs.statSync(path); + + return { + size: stats.size, + type: "image/jpg", + path, + }; +}; + +/** + * generate a temporary thumbnail file. + * + * @returns {temporaryThumbnailFile} - the temporary thumbnail file information + */ +const getTemporaryThumbnailFile = (): temporaryThumbnailFile => { + const filePath = `${getTmpFile()}.jpg`; + const fileName = filePath.split("/").pop(); + const folder = filePath.substring(0, filePath.lastIndexOf("/")); + + return { + folder, + fileName, + filePath, + }; +}; + +/** + * Detect video dimensions + * + * @param {String} videoPath - the video path + * @returns {Promise<{ width: number, height: number }>} - the video dimensions + */ +async function getVideoDimensions(videoPath: string): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + ffmpeg.ffprobe(videoPath, (err, metadata) => { + if (err) { + reject(err); + } + + const { width, height } = metadata.streams[0]; + resolve({ width, height }); + }); + }); +} + +/** + * Calculate the thumbnail dimensions. + * maximum dimension is 1080p and the minimum dimension is 320x240. + * The aspect ratio is preserved. + * + * @param {Number} width - the video width + * @param {Number} height - the video height + * @returns { { width: number, height: number } } - the thumbnail dimensions + */ +function calculateThumbnailDimensions( + width: number, + height: number, +): { + width: number; + height: number; +} { + let newWidth = Math.min(width, 1920); + let newHeight = Math.min(height, 1080); + const ratio = width / height; + + if (width > 1920 || height > 1080) { + newWidth = Math.min(1920, width); + newHeight = Math.min(1080, newWidth / ratio); + } + + if (width < 320 || height < 240) { + newWidth = Math.max(320, width); + newHeight = Math.max(240, newWidth / ratio); + } + + return { width: newWidth, height: newHeight }; +} diff --git a/tdrive/backend/node/src/services/previews/types.ts b/tdrive/backend/node/src/services/previews/types.ts new file mode 100644 index 00000000..a19ed90d --- /dev/null +++ b/tdrive/backend/node/src/services/previews/types.ts @@ -0,0 +1,61 @@ +export type PreviewMessageQueueRequest = { + document: { + id: string; + provider: string; + path: string; + encryption_algo?: string; + encryption_key?: string; + chunks?: number; + mime?: string; + filename?: string; + }; + output: { + provider: string; + path: string; + encryption_algo?: string; + encryption_key?: string; + pages?: number; //Max number of pages for the document + width?: number; //Max width for the thumbnails + height?: number; //Max height for the thumbnails + }; +}; + +export type PreviewClearMessageQueueRequest = { + document: { + id: string; + provider: string; + path: string; + thumbnails_number: number; + }; +}; + +export type PreviewMessageQueueCallback = { + document: { + id: string; + path: string; + provider: string; + }; + thumbnails: { + path: string; + size: number; + type: string; + width: number; + height: number; + provider?: string; + index?: number; + }[]; +}; + +export type ThumbnailResult = { + path: string; + width: number; + height: number; + size: number; + type: string; +}; + +export type temporaryThumbnailFile = { + filePath: string; + fileName: string; + folder: string; +}; diff --git a/tdrive/backend/node/src/services/previews/utils.ts b/tdrive/backend/node/src/services/previews/utils.ts new file mode 100644 index 00000000..55c560cd --- /dev/null +++ b/tdrive/backend/node/src/services/previews/utils.ts @@ -0,0 +1,11 @@ +import mimes from "../../utils/mime"; + +export const TIMEOUT = 5 * 1000; +export const MAX_SIZE = 5 * 1024 * 1024; + +export function isFileType(fileMime: string, fileName: string, requiredExtensions: string[]): any { + const extension = fileName.split(".").pop(); + const secondaryExtensions = Object.keys(mimes).filter(k => mimes[k] === fileMime); + const fileExtensions = [extension, ...secondaryExtensions]; + return fileExtensions.some(e => requiredExtensions.includes(e)); +}