mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-12 05:28:28 +09:00
590 lines
19 KiB
TypeScript
590 lines
19 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {execFile} from 'node:child_process';
|
|
import {createHash} from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import {promisify} from 'node:util';
|
|
import {Logger} from '@app/api/Logger';
|
|
import {isJsonRecord, parseJsonArray} from '@app/api/utils/JsonBoundaryUtils';
|
|
import sharp from 'sharp';
|
|
import {temporaryFile} from 'tempy';
|
|
|
|
const execFilePromise = promisify(execFile);
|
|
|
|
interface ProcessedMediaFile {
|
|
filePath: string;
|
|
contentType: string;
|
|
contentHash: string;
|
|
contentLength: number;
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
|
|
interface StrippedMediaBuffer {
|
|
body: Uint8Array;
|
|
contentType: string;
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
|
|
function normalizeContentType(contentType: string): string {
|
|
return contentType.split(';', 1)[0]?.trim().toLowerCase() || 'application/octet-stream';
|
|
}
|
|
|
|
function isMediaContentType(contentType: string): boolean {
|
|
return contentType.startsWith('image/') || contentType.startsWith('video/') || contentType.startsWith('audio/');
|
|
}
|
|
|
|
function isJpegContentType(contentType: string): boolean {
|
|
return contentType.includes('jpeg') || contentType.includes('jpg');
|
|
}
|
|
|
|
function imageContentTypeForSharpFormat(format: string | undefined, fallback: string): string {
|
|
switch (format) {
|
|
case 'png':
|
|
return 'image/png';
|
|
case 'gif':
|
|
return 'image/gif';
|
|
case 'webp':
|
|
return 'image/webp';
|
|
case 'avif':
|
|
case 'heif':
|
|
return 'image/avif';
|
|
case 'tiff':
|
|
return 'image/tiff';
|
|
case 'svg':
|
|
return 'image/svg+xml';
|
|
case 'jxl':
|
|
return 'image/jxl';
|
|
default:
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
export async function stripNonJpegImageMetadata(data: Uint8Array): Promise<Uint8Array> {
|
|
return (await stripNonJpegImageMetadataForUpload(data, 'application/octet-stream')).body;
|
|
}
|
|
|
|
export async function stripNonJpegImageMetadataForUpload(
|
|
data: Uint8Array,
|
|
contentType: string,
|
|
): Promise<StrippedMediaBuffer> {
|
|
const normalizedContentType = normalizeContentType(contentType);
|
|
if (isPng(data)) {
|
|
return {
|
|
body: stripPngMetadataChunks(data),
|
|
contentType: normalizedContentType === 'image/apng' ? 'image/apng' : 'image/png',
|
|
};
|
|
}
|
|
const strippedWebp = isWebp(data) ? stripWebpMetadataChunks(data) : null;
|
|
if (strippedWebp) return {body: strippedWebp, contentType: 'image/webp'};
|
|
const image = sharp(data, {animated: true});
|
|
const metadata = await image.metadata();
|
|
switch (metadata.format) {
|
|
case 'png':
|
|
return {body: await image.png().toBuffer(), contentType: 'image/png'};
|
|
case 'gif':
|
|
return {body: await image.gif().toBuffer(), contentType: 'image/gif'};
|
|
case 'webp':
|
|
return {body: await image.webp({lossless: true}).toBuffer(), contentType: 'image/webp'};
|
|
case 'heif':
|
|
return {body: await image.avif().toBuffer(), contentType: 'image/avif'};
|
|
case 'tiff':
|
|
return {body: await image.tiff().toBuffer(), contentType: 'image/tiff'};
|
|
default: {
|
|
Logger.warn({format: metadata.format}, 'Unknown image format, trying exiftool metadata stripping');
|
|
const outputContentType = imageContentTypeForSharpFormat(metadata.format, normalizedContentType);
|
|
return {
|
|
body: await stripMetadataWithExiftool(data, imageExtensionForContentType(outputContentType)),
|
|
contentType: outputContentType,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
const PNG_CHUNKS_TO_KEEP = new Set([
|
|
'IHDR',
|
|
'PLTE',
|
|
'IDAT',
|
|
'IEND',
|
|
'tRNS',
|
|
'gAMA',
|
|
'cHRM',
|
|
'sRGB',
|
|
'iCCP',
|
|
'cICP',
|
|
'acTL',
|
|
'fcTL',
|
|
'fdAT',
|
|
]);
|
|
|
|
function isPng(data: Uint8Array): boolean {
|
|
if (data.length < PNG_SIGNATURE.length) return false;
|
|
for (let i = 0; i < PNG_SIGNATURE.length; i++) if (data[i] !== PNG_SIGNATURE[i]) return false;
|
|
return true;
|
|
}
|
|
|
|
function isCriticalPngChunk(type: string): boolean {
|
|
const first = type.charCodeAt(0);
|
|
return first >= 0x41 && first <= 0x5a;
|
|
}
|
|
|
|
function stripPngMetadataChunks(data: Uint8Array): Uint8Array {
|
|
if (!isPng(data)) return data;
|
|
const chunks: Array<Uint8Array> = [data.slice(0, PNG_SIGNATURE.length)];
|
|
let offset = PNG_SIGNATURE.length;
|
|
while (offset + 12 <= data.length) {
|
|
const length =
|
|
((data[offset]! << 24) | (data[offset + 1]! << 16) | (data[offset + 2]! << 8) | data[offset + 3]!) >>> 0;
|
|
const chunkEnd = offset + 12 + length;
|
|
if (chunkEnd > data.length) {
|
|
throw new Error('Invalid PNG chunk length while stripping metadata');
|
|
}
|
|
const type = String.fromCharCode(data[offset + 4]!, data[offset + 5]!, data[offset + 6]!, data[offset + 7]!);
|
|
if (PNG_CHUNKS_TO_KEEP.has(type) || isCriticalPngChunk(type)) {
|
|
chunks.push(data.slice(offset, chunkEnd));
|
|
}
|
|
offset = chunkEnd;
|
|
if (type === 'IEND') break;
|
|
}
|
|
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
const output = new Uint8Array(totalLength);
|
|
let cursor = 0;
|
|
for (const chunk of chunks) {
|
|
output.set(chunk, cursor);
|
|
cursor += chunk.length;
|
|
}
|
|
return output;
|
|
}
|
|
|
|
const WEBP_CHUNKS_TO_KEEP = new Set(['VP8 ', 'VP8L', 'VP8X', 'ALPH', 'ANIM', 'ANMF', 'ICCP']);
|
|
const WEBP_VP8X_EXIF_FLAG = 0x08;
|
|
const WEBP_VP8X_XMP_FLAG = 0x04;
|
|
|
|
function readFourCc(data: Uint8Array, offset: number): string {
|
|
return String.fromCharCode(data[offset]!, data[offset + 1]!, data[offset + 2]!, data[offset + 3]!);
|
|
}
|
|
|
|
function readU32LE(data: Uint8Array, offset: number): number {
|
|
return (data[offset]! | (data[offset + 1]! << 8) | (data[offset + 2]! << 16) | (data[offset + 3]! << 24)) >>> 0;
|
|
}
|
|
|
|
function isWebp(data: Uint8Array): boolean {
|
|
return data.length >= 12 && readFourCc(data, 0) === 'RIFF' && readFourCc(data, 8) === 'WEBP';
|
|
}
|
|
|
|
function stripWebpMetadataChunks(data: Uint8Array): Uint8Array | null {
|
|
const riffEnd = Math.min(data.length, 8 + readU32LE(data, 4));
|
|
const chunks: Array<Uint8Array> = [];
|
|
let offset = 12;
|
|
while (offset + 8 <= riffEnd) {
|
|
const length = readU32LE(data, offset + 4);
|
|
const chunkEnd = offset + 8 + length + (length & 1);
|
|
if (offset + 8 + length > riffEnd) return null;
|
|
const type = readFourCc(data, offset);
|
|
if (WEBP_CHUNKS_TO_KEEP.has(type)) {
|
|
const chunk = data.slice(offset, Math.min(chunkEnd, riffEnd));
|
|
if (type === 'VP8X' && length > 0) chunk[8] = (chunk[8] ?? 0) & ~(WEBP_VP8X_EXIF_FLAG | WEBP_VP8X_XMP_FLAG);
|
|
chunks.push(chunk);
|
|
}
|
|
offset = chunkEnd;
|
|
}
|
|
const bodyLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
const output = new Uint8Array(12 + bodyLength);
|
|
output.set(data.subarray(0, 12));
|
|
new DataView(output.buffer).setUint32(4, 4 + bodyLength, true);
|
|
let cursor = 12;
|
|
for (const chunk of chunks) {
|
|
output.set(chunk, cursor);
|
|
cursor += chunk.length;
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function imageExtensionForContentType(contentType: string): string {
|
|
if (contentType.includes('svg')) return 'svg';
|
|
if (contentType.includes('tiff')) return 'tiff';
|
|
if (contentType.includes('bmp')) return 'bmp';
|
|
if (contentType.includes('jxl')) return 'jxl';
|
|
if (contentType.includes('heic')) return 'heic';
|
|
if (contentType.includes('heif')) return 'heif';
|
|
if (contentType.includes('avif')) return 'avif';
|
|
if (contentType.includes('webp')) return 'webp';
|
|
if (contentType.includes('gif')) return 'gif';
|
|
if (contentType.includes('png')) return 'png';
|
|
return 'img';
|
|
}
|
|
|
|
async function stripMetadataWithExiftool(data: Uint8Array, extension: string): Promise<Uint8Array> {
|
|
const filePath = temporaryFile({extension});
|
|
try {
|
|
await fs.promises.writeFile(filePath, data);
|
|
await execFilePromise('exiftool', ['-all=', '-overwrite_original', '-F', filePath]);
|
|
const result = await fs.promises.readFile(filePath);
|
|
return new Uint8Array(result);
|
|
} finally {
|
|
await cleanupTempFiles([filePath]);
|
|
}
|
|
}
|
|
|
|
function ffmpegMuxerForContentType(contentType: string): string | null {
|
|
if (contentType.includes('3gpp')) return '3gp';
|
|
if (contentType.includes('mp4')) return 'mp4';
|
|
if (contentType.includes('webm')) return 'webm';
|
|
if (contentType.includes('quicktime')) return 'mov';
|
|
if (contentType.includes('x-matroska')) return 'matroska';
|
|
if (contentType.includes('x-msvideo')) return 'avi';
|
|
if (contentType.includes('x-flv')) return 'flv';
|
|
if (contentType.includes('mp2t')) return 'mpegts';
|
|
if (contentType.includes('mpeg')) return contentType.startsWith('audio/') ? 'mp3' : 'mpeg';
|
|
if (contentType.includes('x-ms-wmv')) return 'asf';
|
|
if (contentType.includes('wav')) return 'wav';
|
|
if (contentType.includes('flac')) return 'flac';
|
|
if (contentType.includes('aac')) return 'adts';
|
|
if (contentType.includes('aiff')) return 'aiff';
|
|
if (contentType.includes('ogg')) return 'ogg';
|
|
return null;
|
|
}
|
|
|
|
const FFMPEG_FAST_START_MUXERS = new Set(['mp4', 'mov', '3gp']);
|
|
const FFMPEG_DEMUXER_FOR_MUXER: Readonly<Record<string, string>> = {
|
|
'3gp': 'mp4',
|
|
mov: 'mp4',
|
|
webm: 'matroska',
|
|
adts: 'aac',
|
|
};
|
|
const FFMPEG_STRIP_TIMEOUT_MS = 10 * 60 * 1000;
|
|
const FFMPEG_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
const FFPROBE_TIMEOUT_MS = 30 * 1000;
|
|
const FFPROBE_MAX_BUFFER_BYTES = 64 * 1024;
|
|
|
|
function requireFfmpegMuxer(contentType: string): string {
|
|
const muxer = ffmpegMuxerForContentType(contentType);
|
|
if (!muxer) {
|
|
throw new Error(`No ffmpeg output format for content type ${contentType}`);
|
|
}
|
|
return muxer;
|
|
}
|
|
|
|
export function ffmpegMetadataStripArgs(inputPath: string, outputPath: string, contentType: string): Array<string> {
|
|
const muxer = requireFfmpegMuxer(contentType);
|
|
return [
|
|
'-hide_banner',
|
|
'-loglevel',
|
|
'error',
|
|
'-i',
|
|
inputPath,
|
|
'-map',
|
|
'0:v?',
|
|
'-map',
|
|
'0:a?',
|
|
'-map',
|
|
'0:s?',
|
|
'-map_metadata',
|
|
'-1',
|
|
'-map_metadata:s',
|
|
'-1',
|
|
'-map_metadata:c',
|
|
'-1',
|
|
'-map_chapters',
|
|
'-1',
|
|
'-c',
|
|
'copy',
|
|
...(FFMPEG_FAST_START_MUXERS.has(muxer) ? ['-movflags', '+faststart'] : []),
|
|
'-f',
|
|
muxer,
|
|
'-y',
|
|
outputPath,
|
|
];
|
|
}
|
|
|
|
export function ffmpegContainerMatchesContentType(formatNames: string, contentType: string): boolean {
|
|
const muxer = requireFfmpegMuxer(contentType);
|
|
return formatNames
|
|
.trim()
|
|
.split(',')
|
|
.includes(FFMPEG_DEMUXER_FOR_MUXER[muxer] ?? muxer);
|
|
}
|
|
|
|
async function probeFfmpegContainer(inputPath: string): Promise<string> {
|
|
const {stdout} = await execFilePromise(
|
|
'ffprobe',
|
|
['-v', 'error', '-show_entries', 'format=format_name', '-of', 'default=nw=1:nk=1', inputPath],
|
|
{timeout: FFPROBE_TIMEOUT_MS, maxBuffer: FFPROBE_MAX_BUFFER_BYTES},
|
|
);
|
|
return stdout;
|
|
}
|
|
|
|
async function cleanupTempFiles(paths: ReadonlyArray<string>): Promise<
|
|
Array<{
|
|
path: string;
|
|
error: unknown;
|
|
}>
|
|
> {
|
|
const cleanupErrors: Array<{
|
|
path: string;
|
|
error: unknown;
|
|
}> = [];
|
|
await Promise.all(
|
|
paths.map((filePath) =>
|
|
fs.promises.unlink(filePath).catch((error) => {
|
|
cleanupErrors.push({path: filePath, error});
|
|
}),
|
|
),
|
|
);
|
|
return cleanupErrors;
|
|
}
|
|
|
|
async function getJpegQualityEstimate(filePath: string): Promise<number | null> {
|
|
try {
|
|
const {stdout} = await execFilePromise('exiftool', ['-JPEGQualityEstimate#', '-n', '-j', filePath]);
|
|
const [entry] = parseJsonArray(stdout) ?? [];
|
|
if (!isJsonRecord(entry)) {
|
|
return null;
|
|
}
|
|
const value = entry?.JPEGQualityEstimate;
|
|
if (typeof value === 'number' && value >= 1 && value <= 100) {
|
|
return Math.round(value);
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
Logger.warn({error, filePath}, 'Failed to read JPEG quality estimate, falling back to default');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function hashFileSha256(filePath: string): Promise<string> {
|
|
const hash = createHash('sha256');
|
|
const stream = fs.createReadStream(filePath, {highWaterMark: 1024 * 1024});
|
|
try {
|
|
for await (const chunk of stream) {
|
|
hash.update(chunk as Buffer);
|
|
}
|
|
} catch (error) {
|
|
stream.destroy();
|
|
throw error;
|
|
}
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
async function fileSize(filePath: string): Promise<number> {
|
|
const stat = await fs.promises.stat(filePath);
|
|
return stat.size;
|
|
}
|
|
|
|
async function processJpegFileToFile(
|
|
inputPath: string,
|
|
outputPath: string,
|
|
contentType: string,
|
|
): Promise<{contentType: string; width?: number; height?: number}> {
|
|
const metadata = await sharp(inputPath).metadata();
|
|
const needsRotation = metadata.orientation != null && metadata.orientation > 1;
|
|
let preserveIcc = true;
|
|
const rotatedPath = needsRotation ? temporaryFile({extension: 'jpg'}) : null;
|
|
try {
|
|
if (needsRotation && rotatedPath) {
|
|
const sourceQuality = await getJpegQualityEstimate(inputPath);
|
|
const chromaSubsampling = metadata.chromaSubsampling === '4:4:4' ? '4:4:4' : '4:2:0';
|
|
await sharp(inputPath)
|
|
.rotate()
|
|
.jpeg({quality: sourceQuality ?? 95, chromaSubsampling})
|
|
.toFile(rotatedPath);
|
|
preserveIcc = metadata.space !== 'cmyk';
|
|
await fs.promises.copyFile(rotatedPath, outputPath);
|
|
} else {
|
|
await fs.promises.copyFile(inputPath, outputPath);
|
|
}
|
|
await stripJpegMetadata(outputPath, inputPath, {preserveIcc, rotated: needsRotation});
|
|
} finally {
|
|
if (rotatedPath) {
|
|
await cleanupTempFiles([rotatedPath]);
|
|
}
|
|
}
|
|
const dimensions =
|
|
metadata.width && metadata.height
|
|
? metadata.orientation != null && metadata.orientation >= 5 && metadata.orientation <= 8
|
|
? {width: metadata.height, height: metadata.width}
|
|
: {width: metadata.width, height: metadata.height}
|
|
: undefined;
|
|
return {contentType, ...dimensions};
|
|
}
|
|
|
|
async function stripNonJpegImageFileToFile(
|
|
inputPath: string,
|
|
outputPath: string,
|
|
contentType: string,
|
|
): Promise<{contentType: string; width?: number; height?: number}> {
|
|
const normalizedContentType = normalizeContentType(contentType);
|
|
const probe = await sharp(inputPath).metadata();
|
|
const width = probe.width;
|
|
const height = probe.height;
|
|
const dimensions = width && height ? {width, height} : undefined;
|
|
if (await isPngFile(inputPath)) {
|
|
await stripPngMetadataChunksToFile(inputPath, outputPath);
|
|
return {contentType: normalizedContentType === 'image/apng' ? 'image/apng' : 'image/png', ...dimensions};
|
|
}
|
|
const image = sharp(inputPath, {animated: true});
|
|
const meta = await image.metadata();
|
|
switch (meta.format) {
|
|
case 'png':
|
|
await image.png().toFile(outputPath);
|
|
return {contentType: 'image/png', ...dimensions};
|
|
case 'gif':
|
|
await image.gif().toFile(outputPath);
|
|
return {contentType: 'image/gif', ...dimensions};
|
|
case 'webp':
|
|
await image.webp({lossless: true}).toFile(outputPath);
|
|
return {contentType: 'image/webp', ...dimensions};
|
|
case 'heif':
|
|
await image.avif().toFile(outputPath);
|
|
return {contentType: 'image/avif', ...dimensions};
|
|
case 'tiff':
|
|
await image.tiff().toFile(outputPath);
|
|
return {contentType: 'image/tiff', ...dimensions};
|
|
default: {
|
|
Logger.warn({format: meta.format}, 'Unknown image format, trying exiftool metadata stripping');
|
|
const outputContentType = imageContentTypeForSharpFormat(meta.format, normalizedContentType);
|
|
await stripMetadataWithExiftoolFile(inputPath, outputPath);
|
|
return {contentType: outputContentType, ...dimensions};
|
|
}
|
|
}
|
|
}
|
|
|
|
async function stripVideoFileToFile(
|
|
inputPath: string,
|
|
outputPath: string,
|
|
contentType: string,
|
|
): Promise<{contentType: string; width?: number; height?: number}> {
|
|
const normalizedContentType = normalizeContentType(contentType);
|
|
const container = await probeFfmpegContainer(inputPath);
|
|
if (!ffmpegContainerMatchesContentType(container, normalizedContentType)) {
|
|
throw new Error(`Container ${container.trim()} does not match content type ${normalizedContentType}`);
|
|
}
|
|
await execFilePromise('ffmpeg', ffmpegMetadataStripArgs(inputPath, outputPath, normalizedContentType), {
|
|
timeout: FFMPEG_STRIP_TIMEOUT_MS,
|
|
maxBuffer: FFMPEG_MAX_BUFFER_BYTES,
|
|
});
|
|
return {contentType: normalizedContentType};
|
|
}
|
|
|
|
export async function processMediaFile(
|
|
inputPath: string,
|
|
outputPath: string,
|
|
contentType: string,
|
|
): Promise<ProcessedMediaFile | null> {
|
|
const normalized = normalizeContentType(contentType);
|
|
if (!isMediaContentType(normalized)) {
|
|
return null;
|
|
}
|
|
let result: {contentType: string; width?: number; height?: number};
|
|
if (isJpegContentType(normalized)) {
|
|
result = await processJpegFileToFile(inputPath, outputPath, normalized);
|
|
} else if (normalized.startsWith('image/')) {
|
|
result = await stripNonJpegImageFileToFile(inputPath, outputPath, normalized);
|
|
} else if (normalized.startsWith('video/') || normalized.startsWith('audio/')) {
|
|
result = await stripVideoFileToFile(inputPath, outputPath, normalized);
|
|
} else {
|
|
return null;
|
|
}
|
|
const [contentHash, contentLength] = await Promise.all([hashFileSha256(outputPath), fileSize(outputPath)]);
|
|
return {
|
|
filePath: outputPath,
|
|
contentType: result.contentType,
|
|
contentHash,
|
|
contentLength,
|
|
...(result.width && result.height ? {width: result.width, height: result.height} : {}),
|
|
};
|
|
}
|
|
|
|
async function isPngFile(filePath: string): Promise<boolean> {
|
|
const handle = await fs.promises.open(filePath, 'r');
|
|
try {
|
|
const buf = Buffer.alloc(PNG_SIGNATURE.length);
|
|
const {bytesRead} = await handle.read(buf, 0, PNG_SIGNATURE.length, 0);
|
|
if (bytesRead < PNG_SIGNATURE.length) return false;
|
|
for (let i = 0; i < PNG_SIGNATURE.length; i++) {
|
|
if (buf[i] !== PNG_SIGNATURE[i]) return false;
|
|
}
|
|
return true;
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
async function stripPngMetadataChunksToFile(inputPath: string, outputPath: string): Promise<void> {
|
|
const reader = await fs.promises.open(inputPath, 'r');
|
|
const writer = fs.createWriteStream(outputPath);
|
|
try {
|
|
await writer.write(PNG_SIGNATURE);
|
|
let position = PNG_SIGNATURE.length;
|
|
const header = Buffer.alloc(8);
|
|
while (true) {
|
|
const headRead = await reader.read(header, 0, 8, position);
|
|
if (headRead.bytesRead < 8) break;
|
|
const length = ((header[0]! << 24) | (header[1]! << 16) | (header[2]! << 8) | header[3]!) >>> 0;
|
|
const type = String.fromCharCode(header[4]!, header[5]!, header[6]!, header[7]!);
|
|
const chunkTotal = 8 + length + 4;
|
|
const keep = PNG_CHUNKS_TO_KEEP.has(type) || isCriticalPngChunk(type);
|
|
if (keep) {
|
|
const chunk = Buffer.alloc(chunkTotal);
|
|
const {bytesRead} = await reader.read(chunk, 0, chunkTotal, position);
|
|
if (bytesRead < chunkTotal) {
|
|
throw new Error('Invalid PNG chunk length while stripping metadata');
|
|
}
|
|
if (!writer.write(chunk)) {
|
|
await new Promise<void>((resolve) => writer.once('drain', () => resolve()));
|
|
}
|
|
}
|
|
position += chunkTotal;
|
|
if (type === 'IEND') break;
|
|
}
|
|
await new Promise<void>((resolve, reject) => {
|
|
writer.end((err: Error | null | undefined) => (err ? reject(err) : resolve()));
|
|
});
|
|
} catch (error) {
|
|
writer.destroy();
|
|
throw error;
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
}
|
|
|
|
async function stripMetadataWithExiftoolFile(inputPath: string, outputPath: string): Promise<void> {
|
|
await fs.promises.copyFile(inputPath, outputPath);
|
|
await execFilePromise('exiftool', ['-all=', '-overwrite_original', '-F', outputPath]);
|
|
}
|
|
|
|
async function stripJpegMetadata(
|
|
filePath: string,
|
|
originalFilePath: string,
|
|
opts: {
|
|
preserveIcc: boolean;
|
|
rotated: boolean;
|
|
},
|
|
): Promise<void> {
|
|
if (opts.rotated) {
|
|
await execFilePromise('exiftool', [
|
|
'-all=',
|
|
'-tagsfromfile',
|
|
originalFilePath,
|
|
...(opts.preserveIcc ? ['-icc_profile'] : []),
|
|
'-jfif:XResolution<XResolution',
|
|
'-jfif:YResolution<YResolution',
|
|
'-jfif:ResolutionUnit<ResolutionUnit',
|
|
'-overwrite_original',
|
|
'-F',
|
|
filePath,
|
|
]);
|
|
return;
|
|
}
|
|
await execFilePromise('exiftool', [
|
|
'-all=',
|
|
'--JFIF:all',
|
|
...(opts.preserveIcc ? ['--ICC_Profile:all'] : []),
|
|
'-overwrite_original',
|
|
'-F',
|
|
filePath,
|
|
]);
|
|
}
|