Skip to content

Commit c06a0b4

Browse files
authored
Merge pull request #170 from internxt/feat/pb-1698-generate-thumbnails-on-upload
[PB-1698]: feat/generate-thumbnails-on-upload
2 parents 6ad2a42 + 255c2d9 commit c06a0b4

12 files changed

Lines changed: 450 additions & 81 deletions

File tree

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"selfsigned": "2.4.1",
6363
"sequelize": "6.37.5",
6464
"sequelize-typescript": "2.1.6",
65+
"sharp": "0.33.5",
6566
"sqlite3": "5.1.7",
6667
"tty-table": "4.2.3",
6768
"winston": "3.17.0"
@@ -75,13 +76,13 @@
7576
"@types/cli-progress": "3.11.6",
7677
"@types/express": "5.0.0",
7778
"@types/mime-types": "2.1.4",
78-
"@types/node": "22.10.6",
79+
"@types/node": "22.10.7",
7980
"@types/range-parser": "1.2.7",
8081
"@vitest/coverage-istanbul": "2.1.8",
8182
"@vitest/spy": "2.1.8",
8283
"eslint": "9.18.0",
8384
"husky": "9.1.7",
84-
"lint-staged": "15.3.0",
85+
"lint-staged": "15.4.0",
8586
"nock": "13.5.6",
8687
"nodemon": "3.1.9",
8788
"oclif": "4.17.13",

src/commands/upload-file.ts

Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import { ErrorUtils } from '../utils/errors.utils';
1515
import { MissingCredentialsError, NotValidDirectoryError, NotValidFolderUuidError } from '../types/command.types';
1616
import { ValidationService } from '../services/validation.service';
1717
import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types';
18+
import { ThumbnailService } from '../services/thumbnail.service';
19+
import { BufferStream } from '../utils/stream.utils';
20+
import { isFileThumbnailable } from '../utils/thumbnail.utils';
21+
import { Readable } from 'node:stream';
1822

1923
export default class UploadFile extends Command {
2024
static readonly args = {};
@@ -52,6 +56,9 @@ export default class UploadFile extends Command {
5256
throw new Error('The file is empty. Uploading empty files is not allowed.');
5357
}
5458

59+
const fileInfo = path.parse(filePath);
60+
const fileType = fileInfo.ext.replaceAll('.', '');
61+
5562
let destinationFolderUuid = await this.getDestinationFolderUuid(flags['destination'], nonInteractive);
5663
if (destinationFolderUuid.trim().length === 0) {
5764
// destinationFolderUuid is empty from flags&prompt, which means we should use RootFolderUuid
@@ -75,45 +82,34 @@ export default class UploadFile extends Command {
7582
CLIUtils.done();
7683

7784
// 2. Upload file to the Network
78-
const fileStream = createReadStream(filePath);
85+
const readStream = createReadStream(filePath);
7986
const timer = CLIUtils.timer();
8087
const progressBar = CLIUtils.progress({
8188
format: 'Uploading file [{bar}] {percentage}%',
8289
linewrap: true,
8390
});
8491
progressBar.start(100, 0);
8592

86-
const minimumMultipartThreshold = 100 * 1024 * 1024;
87-
const useMultipart = stats.size > minimumMultipartThreshold;
88-
const partSize = 30 * 1024 * 1024;
89-
const parts = Math.ceil(stats.size / partSize);
90-
91-
let uploadOperation: Promise<
92-
[
93-
Promise<{
94-
fileId: string;
95-
hash: Buffer;
96-
}>,
97-
AbortController,
98-
]
99-
>;
100-
101-
if (useMultipart) {
102-
uploadOperation = networkFacade.uploadMultipartFromStream(user.bucket, user.mnemonic, stats.size, fileStream, {
103-
parts,
104-
progressCallback: (progress) => {
105-
progressBar.update(progress * 0.99);
106-
},
107-
});
108-
} else {
109-
uploadOperation = networkFacade.uploadFromStream(user.bucket, user.mnemonic, stats.size, fileStream, {
110-
progressCallback: (progress) => {
111-
progressBar.update(progress * 0.99);
112-
},
113-
});
93+
let bufferStream: BufferStream | undefined;
94+
let fileStream: Readable = readStream;
95+
const isThumbnailable = isFileThumbnailable(fileType);
96+
if (isThumbnailable) {
97+
bufferStream = new BufferStream();
98+
fileStream = readStream.pipe(bufferStream);
11499
}
115100

116-
const [uploadPromise, abortable] = await uploadOperation;
101+
const progressCallback = (progress: number) => {
102+
progressBar.update(progress * 0.99);
103+
};
104+
105+
const [uploadPromise, abortable] = await UploadService.instance.uploadFileStream(
106+
fileStream,
107+
user.bucket,
108+
user.mnemonic,
109+
stats.size,
110+
networkFacade,
111+
progressCallback,
112+
);
117113

118114
process.on('SIGINT', () => {
119115
abortable.abort('SIGINT received');
@@ -123,10 +119,9 @@ export default class UploadFile extends Command {
123119
const uploadResult = await uploadPromise;
124120

125121
// 3. Create the file in Drive
126-
const fileInfo = path.parse(filePath);
127122
const createdDriveFile = await DriveFileService.instance.createFile({
128123
plain_name: fileInfo.name,
129-
type: fileInfo.ext.replaceAll('.', ''),
124+
type: fileType,
130125
size: stats.size,
131126
folder_id: destinationFolderUuid,
132127
id: uploadResult.fileId,
@@ -135,6 +130,25 @@ export default class UploadFile extends Command {
135130
name: '',
136131
});
137132

133+
try {
134+
if (isThumbnailable && bufferStream) {
135+
const thumbnailBuffer = bufferStream.getBuffer();
136+
137+
if (thumbnailBuffer) {
138+
await ThumbnailService.instance.uploadThumbnail(
139+
thumbnailBuffer,
140+
fileType,
141+
user.bucket,
142+
user.mnemonic,
143+
createdDriveFile.id,
144+
networkFacade,
145+
);
146+
}
147+
}
148+
} catch (error) {
149+
ErrorUtils.report(this.error.bind(this), error, { command: this.id });
150+
}
151+
138152
progressBar.update(100);
139153
progressBar.stop();
140154

src/services/drive/drive-file.service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,9 @@ export class DriveFileService {
5151
const fileMetadata = await storageClient.getFileByPath(encodeURIComponent(path));
5252
return DriveUtils.driveFileMetaToItem(fileMetadata);
5353
};
54+
55+
public createThumbnail = (payload: StorageTypes.ThumbnailEntry): Promise<StorageTypes.Thumbnail> => {
56+
const storageClient = SdkManager.instance.getStorage(false);
57+
return storageClient.createThumbnailEntry(payload);
58+
};
5459
}

src/services/network/network-facade.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export class NetworkFacade {
165165
};
166166

167167
const uploadFile: UploadFileFunction = async (url) => {
168-
await this.uploadService.uploadFile(url, encryptionTransform, {
168+
await this.uploadService.uploadFileToNetwork(url, encryptionTransform, {
169169
abortController: abortable,
170170
progressCallback: onProgress,
171171
});
@@ -244,7 +244,7 @@ export class NetworkFacade {
244244
const limitConcurrency = 6;
245245

246246
const uploadPart = async (upload: UploadTask) => {
247-
const { etag } = await this.uploadService.uploadFile(upload.urlToUpload, upload.contentToUpload, {
247+
const { etag } = await this.uploadService.uploadFileToNetwork(upload.urlToUpload, upload.contentToUpload, {
248248
abortController: abortable,
249249
progressCallback: (loadedBytes: number) => {
250250
onProgress(upload.index, loadedBytes);

src/services/network/upload.service.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { Readable } from 'node:stream';
22
import axios from 'axios';
33
import { UploadOptions } from '../../types/network.types';
4+
import { NetworkFacade } from './network-facade.service';
45

56
export class UploadService {
67
public static readonly instance: UploadService = new UploadService();
78

8-
async uploadFile(url: string, from: Readable | Buffer, options: UploadOptions): Promise<{ etag: string }> {
9+
public uploadFileToNetwork = async (
10+
url: string,
11+
from: Readable | Buffer,
12+
options: UploadOptions,
13+
): Promise<{ etag: string }> => {
914
const response = await axios.put(url, from, {
1015
signal: options.abortController?.signal,
1116
onUploadProgress: (progressEvent) => {
@@ -20,5 +25,44 @@ export class UploadService {
2025
throw new Error('Missing Etag in response when uploading file');
2126
}
2227
return { etag };
23-
}
28+
};
29+
30+
public uploadFileStream = async (
31+
fileStream: Readable,
32+
userBucket: string,
33+
userMnemonic: string,
34+
fileSize: number,
35+
networkFacade: NetworkFacade,
36+
progressCallback?: (progress: number) => void,
37+
) => {
38+
const minimumMultipartThreshold = 100 * 1024 * 1024;
39+
const useMultipart = fileSize > minimumMultipartThreshold;
40+
const partSize = 30 * 1024 * 1024;
41+
const parts = Math.ceil(fileSize / partSize);
42+
43+
let uploadOperation: Promise<
44+
[
45+
Promise<{
46+
fileId: string;
47+
hash: Buffer;
48+
}>,
49+
AbortController,
50+
]
51+
>;
52+
53+
if (useMultipart) {
54+
uploadOperation = networkFacade.uploadMultipartFromStream(userBucket, userMnemonic, fileSize, fileStream, {
55+
parts,
56+
progressCallback,
57+
});
58+
} else {
59+
uploadOperation = networkFacade.uploadFromStream(userBucket, userMnemonic, fileSize, fileStream, {
60+
progressCallback,
61+
});
62+
}
63+
64+
const uploadFileOperation = await uploadOperation;
65+
66+
return uploadFileOperation;
67+
};
2468
}

src/services/thumbnail.service.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Readable } from 'node:stream';
2+
import { DriveFileService } from './drive/drive-file.service';
3+
import { StorageTypes } from '@internxt/sdk/dist/drive';
4+
import { NetworkFacade } from './network/network-facade.service';
5+
import { UploadService } from './network/upload.service';
6+
import { isImageThumbnailable, ThumbnailConfig } from '../utils/thumbnail.utils';
7+
import sharp from 'sharp';
8+
9+
export class ThumbnailService {
10+
public static readonly instance: ThumbnailService = new ThumbnailService();
11+
12+
public uploadThumbnail = async (
13+
fileContent: Buffer,
14+
fileType: string,
15+
userBucket: string,
16+
userMnemonic: string,
17+
file_id: number,
18+
networkFacade: NetworkFacade,
19+
): Promise<StorageTypes.Thumbnail | undefined> => {
20+
let thumbnailBuffer: Buffer | undefined;
21+
if (isImageThumbnailable(fileType)) {
22+
thumbnailBuffer = await this.getThumbnailFromImageBuffer(fileContent);
23+
}
24+
if (thumbnailBuffer) {
25+
const size = thumbnailBuffer.length;
26+
const [thumbnailPromise] = await UploadService.instance.uploadFileStream(
27+
Readable.from(thumbnailBuffer),
28+
userBucket,
29+
userMnemonic,
30+
size,
31+
networkFacade,
32+
() => {},
33+
);
34+
35+
const thumbnailUploadResult = await thumbnailPromise;
36+
37+
const createdThumbnailFile = await DriveFileService.instance.createThumbnail({
38+
file_id: file_id,
39+
max_width: ThumbnailConfig.MaxWidth,
40+
max_height: ThumbnailConfig.MaxHeight,
41+
type: ThumbnailConfig.Type,
42+
size: size,
43+
bucket_id: userBucket,
44+
bucket_file: thumbnailUploadResult.fileId,
45+
encrypt_version: StorageTypes.EncryptionVersion.Aes03,
46+
});
47+
return createdThumbnailFile;
48+
}
49+
};
50+
51+
private getThumbnailFromImageBuffer = (buffer: Buffer): Promise<Buffer> => {
52+
return sharp(buffer)
53+
.resize({
54+
height: ThumbnailConfig.MaxHeight,
55+
width: ThumbnailConfig.MaxWidth,
56+
fit: 'inside',
57+
})
58+
.png({
59+
quality: ThumbnailConfig.Quality,
60+
})
61+
.toBuffer();
62+
};
63+
}

src/types/network.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export interface NetworkCredentials {
66
export type DownloadProgressCallback = (downloadedBytes: number) => void;
77
export type UploadProgressCallback = (uploadedBytes: number) => void;
88
export interface NetworkOperationBaseOptions {
9-
progressCallback: UploadProgressCallback;
9+
progressCallback?: UploadProgressCallback;
1010
abortController?: AbortController;
1111
}
1212

src/utils/stream.utils.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ReadStream, WriteStream } from 'node:fs';
2-
import { Readable, Transform, TransformCallback } from 'node:stream';
2+
import { Readable, Transform, TransformCallback, TransformOptions } from 'node:stream';
33

44
export class StreamUtils {
55
static readStreamToReadableStream(readStream: ReadStream): ReadableStream<Uint8Array> {
@@ -130,3 +130,30 @@ export class ProgressTransform extends Transform {
130130
callback(null);
131131
}
132132
}
133+
134+
export class BufferStream extends Transform {
135+
public buffer: Buffer | null;
136+
137+
constructor(opts?: TransformOptions) {
138+
super(opts);
139+
this.buffer = null;
140+
}
141+
142+
_transform(chunk: Buffer, _: BufferEncoding, callback: TransformCallback) {
143+
const currentBuffer = this.buffer ?? Buffer.alloc(0);
144+
this.buffer = Buffer.concat([currentBuffer, chunk]);
145+
callback(null, chunk);
146+
}
147+
148+
_flush(callback: TransformCallback) {
149+
callback();
150+
}
151+
152+
reset() {
153+
this.buffer = null;
154+
}
155+
156+
getBuffer(): Buffer | null {
157+
return this.buffer;
158+
}
159+
}

src/utils/thumbnail.utils.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
export const ThumbnailConfig = {
2+
MaxWidth: 300,
3+
MaxHeight: 300,
4+
Quality: 100,
5+
Type: 'png',
6+
} as const;
7+
8+
type FileExtensionMap = Record<string, string[]>;
9+
const imageExtensions: FileExtensionMap = {
10+
tiff: ['tif', 'tiff'],
11+
bmp: ['bmp'],
12+
heic: ['heic'],
13+
jpg: ['jpg', 'jpeg'],
14+
gif: ['gif'],
15+
png: ['png'],
16+
eps: ['eps'],
17+
raw: ['raw', 'cr2', 'nef', 'orf', 'sr2'],
18+
webp: ['webp'],
19+
};
20+
const pdfExtensions: FileExtensionMap = {
21+
pdf: ['pdf'],
22+
};
23+
const thumbnailableImageExtension: string[] = [
24+
...imageExtensions['jpg'],
25+
...imageExtensions['png'],
26+
...imageExtensions['webp'],
27+
...imageExtensions['gif'],
28+
...imageExtensions['tiff'],
29+
];
30+
const thumbnailablePdfExtension: string[] = pdfExtensions['pdf'];
31+
const thumbnailableExtension: string[] = [...thumbnailableImageExtension];
32+
33+
export const isFileThumbnailable = (fileType: string) => {
34+
return fileType.trim().length > 0 && thumbnailableExtension.includes(fileType);
35+
};
36+
37+
export const isPDFThumbnailable = (fileType: string) => {
38+
return fileType.trim().length > 0 && thumbnailablePdfExtension.includes(fileType);
39+
};
40+
41+
export const isImageThumbnailable = (fileType: string) => {
42+
return fileType.trim().length > 0 && thumbnailableImageExtension.includes(fileType);
43+
};

0 commit comments

Comments
 (0)