Merge pull request #128 from ente-io/add-image-thumbnail-generation-api

Add image thumbnail generation api
This commit is contained in:
Abhinav Kumar 2023-01-06 13:06:41 +05:30 committed by GitHub
commit b4307930d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 350 additions and 92 deletions

View file

@ -1,7 +1,7 @@
{
"name": "ente",
"productName": "ente",
"version": "1.6.16",
"version": "1.6.17-alpha.3",
"private": true,
"description": "Desktop client for ente.io",
"main": "app/main.js",

View file

@ -1,9 +0,0 @@
import { ipcRenderer } from 'electron/renderer';
export async function convertHEIC(fileData: Uint8Array): Promise<Uint8Array> {
const convertedFileData = await ipcRenderer.invoke(
'convert-heic',
fileData
);
return convertedFileData;
}

49
src/api/imageProcessor.ts Normal file
View file

@ -0,0 +1,49 @@
import { ipcRenderer } from 'electron/renderer';
import { existsSync } from 'fs';
import { logError } from '../services/logging';
import { ElectronFile } from '../types';
export async function convertHEIC(fileData: Uint8Array): Promise<Uint8Array> {
const convertedFileData = await ipcRenderer.invoke(
'convert-heic',
fileData
);
return convertedFileData;
}
export async function generateImageThumbnail(
inputFile: File | ElectronFile,
maxDimension: number,
maxSize: number
): Promise<Uint8Array> {
let inputFilePath = null;
let createdTempInputFile = null;
try {
if (!existsSync(inputFile.path)) {
const inputFileData = new Uint8Array(await inputFile.arrayBuffer());
inputFilePath = await ipcRenderer.invoke(
'write-temp-file',
inputFileData,
inputFile.name
);
createdTempInputFile = true;
} else {
inputFilePath = inputFile.path;
}
const thumbnail = await ipcRenderer.invoke(
'generate-image-thumbnail',
inputFilePath,
maxDimension,
maxSize
);
return thumbnail;
} finally {
if (createdTempInputFile) {
try {
await ipcRenderer.invoke('remove-temp-file', inputFilePath);
} catch (e) {
logError(e, 'failed to deleteTempFile');
}
}
}
}

View file

@ -48,7 +48,7 @@ import {
} from './api/common';
import { fixHotReloadNext12 } from './utils/preload';
import { isFolder, getDirFiles } from './api/fs';
import { convertHEIC } from './api/heicConvert';
import { convertHEIC, generateImageThumbnail } from './api/imageProcessor';
import { setupLogging } from './utils/logging';
import { setupRendererProcessStatsLogger } from './utils/processStats';
import { runFFmpegCmd } from './api/ffmpeg';
@ -104,4 +104,5 @@ windowObject['ElectronAPIs'] = {
getSentryUserID,
getAppVersion,
runFFmpegCmd,
generateImageThumbnail,
};

View file

@ -9,9 +9,9 @@ import { existsSync } from 'fs';
const execAsync = util.promisify(require('child_process').exec);
export const INPUT_PATH_PLACEHOLDER = 'INPUT';
export const FFMPEG_PLACEHOLDER = 'FFMPEG';
export const OUTPUT_PATH_PLACEHOLDER = 'OUTPUT';
const INPUT_PATH_PLACEHOLDER = 'INPUT';
const FFMPEG_PLACEHOLDER = 'FFMPEG';
const OUTPUT_PATH_PLACEHOLDER = 'OUTPUT';
function getFFmpegStaticPath() {
return pathToFfmpeg.replace('app.asar', 'app.asar.unpacked');
@ -37,9 +37,9 @@ export async function runFFmpegCmd(
return cmdPart;
}
});
cmd = shellescape(cmd);
log.info('cmd', cmd);
await execAsync(cmd);
const escapedCmd = shellescape(cmd);
log.info('running ffmpeg command', escapedCmd);
await execAsync(escapedCmd);
if (!existsSync(tempOutputFilePath)) {
throw new Error('ffmpeg output file not found');
}

View file

@ -1,72 +0,0 @@
import util from 'util';
import { exec } from 'child_process';
import { existsSync, rmSync } from 'fs';
import { readFile, writeFile } from 'promise-fs';
import { generateTempFilePath } from '../utils/temp';
import { logErrorSentry } from './sentry';
import { isPlatform } from '../utils/main';
import { isDev } from '../utils/common';
import path from 'path';
const asyncExec = util.promisify(exec);
function getImageMagickStaticPath() {
return isDev
? 'build/image-magick'
: path.join(process.resourcesPath, 'image-magick');
}
export async function convertHEIC(
heicFileData: Uint8Array
): Promise<Uint8Array> {
let tempInputFilePath: string;
let tempOutputFilePath: string;
try {
tempInputFilePath = await generateTempFilePath('.heic');
tempOutputFilePath = await generateTempFilePath('.jpeg');
await writeFile(tempInputFilePath, heicFileData);
await runConvertCommand(tempInputFilePath, tempOutputFilePath);
if (!existsSync(tempOutputFilePath)) {
throw new Error('heic convert output file not found');
}
const convertedFileData = new Uint8Array(
await readFile(tempOutputFilePath)
);
return convertedFileData;
} catch (e) {
logErrorSentry(e, 'failed to convert heic');
throw e;
} finally {
try {
rmSync(tempInputFilePath, { force: true });
} catch (e) {
logErrorSentry(e, 'failed to remove tempInputFile');
}
try {
rmSync(tempOutputFilePath, { force: true });
} catch (e) {
logErrorSentry(e, 'failed to remove tempOutputFile');
}
}
}
async function runConvertCommand(
tempInputFilePath: string,
tempOutputFilePath: string
) {
if (isPlatform('mac')) {
await asyncExec(
`sips -s format jpeg ${tempInputFilePath} --out ${tempOutputFilePath}`
);
} else if (isPlatform('linux')) {
await asyncExec(
`${getImageMagickStaticPath()} ${tempInputFilePath} -quality 100% ${tempOutputFilePath}`
);
} else {
Error(`${process.platform} native heic convert not supported yet`);
}
}

View file

@ -0,0 +1,279 @@
import util from 'util';
import { exec } from 'child_process';
import { existsSync, rmSync } from 'fs';
import { readFile, writeFile } from 'promise-fs';
import { generateTempFilePath } from '../utils/temp';
import { logErrorSentry } from './sentry';
import { isPlatform } from '../utils/main';
import { isDev } from '../utils/common';
import path from 'path';
import log from 'electron-log';
const shellescape = require('any-shell-escape');
const asyncExec = util.promisify(exec);
const IMAGE_MAGICK_PLACEHOLDER = 'IMAGE_MAGICK';
const MAX_DIMENSION_PLACEHOLDER = 'MAX_DIMENSION';
const SAMPLE_SIZE_PLACEHOLDER = 'SAMPLE_SIZE';
const INPUT_PATH_PLACEHOLDER = 'INPUT';
const OUTPUT_PATH_PLACEHOLDER = 'OUTPUT';
const QUALITY_PLACEHOLDER = 'QUALITY';
const MAX_QUALITY = 70;
const MIN_QUALITY = 50;
const SIPS_HEIC_CONVERT_COMMAND_TEMPLATE = [
'sips',
'-s',
'format',
'jpeg',
INPUT_PATH_PLACEHOLDER,
'--out',
OUTPUT_PATH_PLACEHOLDER,
];
const SIPS_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [
'sips',
'-s',
'format',
'jpeg',
'-s',
'formatOptions',
QUALITY_PLACEHOLDER,
'-Z',
MAX_DIMENSION_PLACEHOLDER,
INPUT_PATH_PLACEHOLDER,
'--out',
OUTPUT_PATH_PLACEHOLDER,
];
const IMAGEMAGICK_HEIC_CONVERT_COMMAND_TEMPLATE = [
IMAGE_MAGICK_PLACEHOLDER,
INPUT_PATH_PLACEHOLDER,
'-quality',
'100%',
OUTPUT_PATH_PLACEHOLDER,
];
const IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [
IMAGE_MAGICK_PLACEHOLDER,
'-define',
`jpeg:size=${SAMPLE_SIZE_PLACEHOLDER}x${SAMPLE_SIZE_PLACEHOLDER}`,
INPUT_PATH_PLACEHOLDER,
'-thumbnail',
`${MAX_DIMENSION_PLACEHOLDER}x${MAX_DIMENSION_PLACEHOLDER}>`,
'-unsharp',
'0x.5',
'-quality',
QUALITY_PLACEHOLDER,
OUTPUT_PATH_PLACEHOLDER,
];
function getImageMagickStaticPath() {
return isDev
? 'build/image-magick'
: path.join(process.resourcesPath, 'image-magick');
}
export async function convertHEIC(
heicFileData: Uint8Array
): Promise<Uint8Array> {
let tempInputFilePath: string;
let tempOutputFilePath: string;
try {
tempInputFilePath = await generateTempFilePath('input.heic');
tempOutputFilePath = await generateTempFilePath('output.jpeg');
await writeFile(tempInputFilePath, heicFileData);
await runConvertCommand(tempInputFilePath, tempOutputFilePath);
if (!existsSync(tempOutputFilePath)) {
throw new Error('heic convert output file not found');
}
const convertedFileData = new Uint8Array(
await readFile(tempOutputFilePath)
);
return convertedFileData;
} catch (e) {
logErrorSentry(e, 'failed to convert heic');
throw e;
} finally {
try {
rmSync(tempInputFilePath, { force: true });
} catch (e) {
logErrorSentry(e, 'failed to remove tempInputFile');
}
try {
rmSync(tempOutputFilePath, { force: true });
} catch (e) {
logErrorSentry(e, 'failed to remove tempOutputFile');
}
}
}
async function runConvertCommand(
tempInputFilePath: string,
tempOutputFilePath: string
) {
const convertCmd = constructConvertCommand(
tempInputFilePath,
tempOutputFilePath
);
const escapedCmd = shellescape(convertCmd);
log.info('running convert command: ' + escapedCmd);
await asyncExec(escapedCmd);
}
function constructConvertCommand(
tempInputFilePath: string,
tempOutputFilePath: string
) {
let convertCmd: string[];
if (isPlatform('mac')) {
convertCmd = SIPS_HEIC_CONVERT_COMMAND_TEMPLATE.map((cmdPart) => {
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return tempInputFilePath;
}
if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
return tempOutputFilePath;
}
return cmdPart;
});
} else if (isPlatform('linux')) {
convertCmd = IMAGEMAGICK_HEIC_CONVERT_COMMAND_TEMPLATE.map(
(cmdPart) => {
if (cmdPart === IMAGE_MAGICK_PLACEHOLDER) {
return getImageMagickStaticPath();
}
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return tempInputFilePath;
}
if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
return tempOutputFilePath;
}
return cmdPart;
}
);
} else {
Error(`${process.platform} native heic convert not supported yet`);
}
return convertCmd;
}
export async function generateImageThumbnail(
inputFilePath: string,
width: number,
maxSize: number
): Promise<Uint8Array> {
let tempOutputFilePath: string;
let quality = MAX_QUALITY;
try {
tempOutputFilePath = await generateTempFilePath('thumb.jpeg');
let thumbnail: Uint8Array;
do {
await runThumbnailGenerationCommand(
inputFilePath,
tempOutputFilePath,
width,
quality
);
if (!existsSync(tempOutputFilePath)) {
throw new Error('output thumbnail file not found');
}
thumbnail = new Uint8Array(await readFile(tempOutputFilePath));
quality -= 10;
} while (thumbnail.length > maxSize && quality > MIN_QUALITY);
return thumbnail;
} catch (e) {
logErrorSentry(e, 'generate image thumbnail failed');
throw e;
} finally {
try {
rmSync(tempOutputFilePath, { force: true });
} catch (e) {
logErrorSentry(e, 'failed to remove tempOutputFile');
}
}
}
async function runThumbnailGenerationCommand(
inputFilePath: string,
tempOutputFilePath: string,
maxDimension: number,
quality: number
) {
const thumbnailGenerationCmd: string[] =
constructThumbnailGenerationCommand(
inputFilePath,
tempOutputFilePath,
maxDimension,
quality
);
const escapedCmd = shellescape(thumbnailGenerationCmd);
log.info('running thumbnail generation command: ' + escapedCmd);
await asyncExec(escapedCmd);
}
function constructThumbnailGenerationCommand(
inputFilePath: string,
tempOutputFilePath: string,
maxDimension: number,
quality: number
) {
let thumbnailGenerationCmd: string[];
if (isPlatform('mac')) {
thumbnailGenerationCmd = SIPS_THUMBNAIL_GENERATE_COMMAND_TEMPLATE.map(
(cmdPart) => {
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return inputFilePath;
}
if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
return tempOutputFilePath;
}
if (cmdPart === MAX_DIMENSION_PLACEHOLDER) {
return maxDimension.toString();
}
if (cmdPart === QUALITY_PLACEHOLDER) {
return quality.toString();
}
return cmdPart;
}
);
} else if (isPlatform('linux')) {
thumbnailGenerationCmd =
IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE.map((cmdPart) => {
if (cmdPart === IMAGE_MAGICK_PLACEHOLDER) {
return getImageMagickStaticPath();
}
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return inputFilePath;
}
if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
return tempOutputFilePath;
}
if (cmdPart.includes(SAMPLE_SIZE_PLACEHOLDER)) {
return cmdPart.replaceAll(
SAMPLE_SIZE_PLACEHOLDER,
(2 * maxDimension).toString()
);
}
if (cmdPart.includes(MAX_DIMENSION_PLACEHOLDER)) {
return cmdPart.replaceAll(
MAX_DIMENSION_PLACEHOLDER,
maxDimension.toString()
);
}
if (cmdPart === QUALITY_PLACEHOLDER) {
return quality.toString();
}
return cmdPart;
});
} else {
Error(
`${process.platform} native thumbnail generation not supported yet`
);
}
return thumbnailGenerationCmd;
}

View file

@ -14,7 +14,10 @@ import { getSentryUserID, logErrorSentry } from '../services/sentry';
import chokidar from 'chokidar';
import path from 'path';
import { getDirFilePaths } from '../services/fs';
import { convertHEIC } from '../services/heicConverter';
import {
convertHEIC,
generateImageThumbnail,
} from '../services/imageProcessor';
import {
getAppVersion,
skipAppVersion,
@ -146,4 +149,11 @@ export default function setupIpcComs(
ipcMain.handle('remove-temp-file', (_, tempFilePath: string) => {
return deleteTempFile(tempFilePath);
});
ipcMain.handle(
'generate-image-thumbnail',
(_, fileData, maxDimension, maxSize) => {
return generateImageThumbnail(fileData, maxDimension, maxSize);
}
);
}

View file

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es2017",
"target": "es2021",
"module": "commonjs",
"esModuleInterop": true,
"noImplicitAny": true,

2
ui

@ -1 +1 @@
Subproject commit a8c520a4b0ff90279cffedf41b9e8f8564d9a753
Subproject commit a955ae39fa97232131682ad72d866bf3155a0906