⭐️ Put back preview service (#75)
* Put back the preview service * Fix imports linter
This commit is contained in:
@@ -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<this> {
|
||||
try {
|
||||
await Promise.all([(this.repository = await gr.database.getRepository<File>("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<PreviewMessageQueueRequest>(
|
||||
"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<PreviewClearMessageQueueRequest>(
|
||||
"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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PreviewMessageQueueCallback, string>
|
||||
{
|
||||
constructor(readonly service: FileServiceImpl, private repository: Repository<File>) {}
|
||||
|
||||
async init(_context?: TdriveContext): Promise<this> {
|
||||
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<string> {
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,5 @@ export type UploadOptions = {
|
||||
totalChunks: number;
|
||||
chunkNumber: number;
|
||||
waitForThumbnail: boolean;
|
||||
ignoreThumbnails: boolean;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Prefix, TdriveService } from "../../core/platform/framework";
|
||||
|
||||
@Prefix("/internal/services/previews/v1")
|
||||
export default class PreviewsService extends TdriveService<undefined> {
|
||||
version = "1";
|
||||
name = "previews";
|
||||
|
||||
public async doInit(): Promise<this> {
|
||||
return this;
|
||||
}
|
||||
|
||||
// TODO: remove
|
||||
api(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -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<PreviewClearMessageQueueRequest, PreviewMessageQueueCallback>
|
||||
{
|
||||
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<this> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
validate(message: PreviewClearMessageQueueRequest): boolean {
|
||||
return !!(message && message.document);
|
||||
}
|
||||
|
||||
async process(message: PreviewClearMessageQueueRequest): Promise<PreviewMessageQueueCallback> {
|
||||
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: [] };
|
||||
}
|
||||
}
|
||||
@@ -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<this> {
|
||||
gr.platformServices.messageQueue.processor.addHandler(new PreviewProcessor());
|
||||
gr.platformServices.messageQueue.processor.addHandler(new ClearProcessor());
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -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<PreviewMessageQueueRequest, PreviewMessageQueueCallback>
|
||||
{
|
||||
readonly name = "PreviewProcessor";
|
||||
|
||||
init?(): Promise<this> {
|
||||
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<PreviewMessageQueueCallback> {
|
||||
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<PreviewMessageQueueCallback> {
|
||||
//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 };
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<this> {
|
||||
return this;
|
||||
}
|
||||
|
||||
async generateThumbnails(
|
||||
document: Pick<PreviewMessageQueueRequest["document"], "filename" | "mime" | "path">,
|
||||
options: PreviewMessageQueueRequest["output"],
|
||||
): Promise<ThumbnailResult[]> {
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -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<ThumbnailResult[]>} - resolves when the thumbnails are generated
|
||||
*/
|
||||
export async function generateVideoPreview(videoPaths: string[]): Promise<ThumbnailResult[]> {
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user