여러 폴더의 사진 파일을 하나의 병합된 폴더에 연도별로 자동 정리해주는 스크립트입니다.
파일명이 중복되지 않는 전재로 합니다.

EXIF 설명은 다음 스크린샷으로 대체합니다.


실제 이미지 파일을 살펴보면 다음과 같은 메타 데이터가 있습니다. 이 메타 데이터에서 촬영 날짜를 활용하여 파일을 분류합니다.

Input은 사진 폴더, Output은 다음과 같이 연도별로 폴더 만들고 그 안에 사진/영상 파일 촬영 날짜 기준으로 정리됩니다

폴더 구조는 다음과 같습니다

연도 하위에 월(月) 폴더는 생성하지 않습니다. 필요시 AI한테 연도 하위에 월 폴더까지 만들어 달라 코드 수정 요청하면 됩니다.
Node.js v22/24 LTS 사용 가능, exiftool 패키지 설치 필요
npm install exiftool-vendored
/**
* merge_by_year.js
*
* 여러 base 폴더의 사진/영상 파일을 재귀 탐색하여
* - MD5 해시
* - EXIF / 메타데이터
* - 파일 수정일(mtime), 생성일(birthtime/ctime)
* - 파일 크기
* 를 추출하고, 결과를 JSON 인덱스 파일로 저장한 뒤
* `<out>/YYYY/` 폴더에 연도별로 통합합니다.
*
* 중복 판별:
* 1차) MD5 해시 동일 → 같은 파일로 간주 (단일본만 유지)
* 2차) 해시가 달라도 (size + 촬영시각 + 원본 파일명)이 모두 동일하면 중복 후보로 기록
*
* 사용법:
* node scripts/merge_by_year.js --out <머지폴더> <base1> [base2] [base3 ...] [옵션]
*
* 옵션:
* --out <경로> 머지 결과를 모을 폴더 (필수)
* --json <경로> 인덱스 JSON 파일 경로 (기본: <out>/_index.json)
* --mode copy|move 파일 복사/이동 모드 (기본: copy). move = copy + 원본 삭제.
* --on-conflict <처리> 대상 파일명이 이미 존재할 때 처리 방식 (기본: dup)
* dup : _dup1, _dup2 접미사로 리네임
* overwrite : 기존 파일을 덮어씀
* ignore : 복사를 건너뜀 (원본도 삭제하지 않음)
* --delete-source 커스텀: 성공적으로 머지된 원본 파일만 삭제 (copy 모드와 조합 가능)
* --dry-run 실제 파일 이동/복사 없이 시뮬레이션만 수행
* --no-merge JSON 인덱스 생성만 수행하고 머지 단계는 건너뜀 * --no-cache 기존 인덱스 JSON 의 캐시된 md5/메타 재사용 비활성화 * --concurrency <N> EXIF/해시 동시 처리 개수 (기본: 4)
*
* 예:
* node scripts/merge_by_year.js --out ./merged "./Photos 2017" "./Photos 2018" "./Photos 2019"
* node scripts/merge_by_year.js --out ./merged ./A ./B --mode move --dry-run
*/
'use strict';
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const crypto = require('crypto');
const { exiftool } = require('exiftool-vendored');
function parseArgs(argv) {
const opts = {
out: null,
json: null,
mode: 'copy',
onConflict: 'dup',
dryRun: false,
noMerge: false,
noCache: false,
deleteSource: false,
concurrency: 8,
bases: [],
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
switch (a) {
case '--out': opts.out = argv[++i]; break;
case '--json': opts.json = argv[++i]; break;
case '--mode': opts.mode = argv[++i]; break;
case '--on-conflict': opts.onConflict = argv[++i]; break;
case '--dry-run': opts.dryRun = true; break;
case '--no-merge': opts.noMerge = true; break;
case '--no-cache': opts.noCache = true; break;
case '--delete-source': opts.deleteSource = true; break;
case '--concurrency': opts.concurrency = Math.max(1, parseInt(argv[++i], 10) || 4); break;
default:
if (a.startsWith('--')) {
throw new Error(`알 수 없는 옵션: ${a}`);
}
opts.bases.push(a);
}
}
if (!opts.out) throw new Error('--out <머지폴더> 옵션이 필요합니다.');
if (opts.bases.length === 0) throw new Error('base 폴더를 최소 1개 이상 지정해야 합니다.');
if (!['copy', 'move'].includes(opts.mode)) {
throw new Error(`--mode 는 copy 또는 move 여야 합니다 (입력값: ${opts.mode})`);
}
if (!['dup', 'overwrite', 'ignore'].includes(opts.onConflict)) {
throw new Error(`--on-conflict 는 dup|overwrite|ignore 중 하나여야 합니다 (입력값: ${opts.onConflict})`);
}
// move mode implies deleteSource
if (opts.mode === 'move') opts.deleteSource = true;
opts.out = path.resolve(opts.out);
opts.bases = opts.bases.map(b => path.resolve(b));
opts.json = opts.json ? path.resolve(opts.json) : path.join(opts.out, '_index.json');
return opts;
}
const SUPPORTED_EXTS = new Set([
// images
'.jpg', '.jpeg', '.heic', '.heif', '.png', '.gif', '.tiff', '.tif',
'.bmp', '.webp', '.dng', '.raw', '.cr2', '.nef', '.arw',
// videos
'.mov', '.mp4', '.m4v', '.avi', '.3gp', '.mkv', '.wmv',
]);
function pad(n) { return String(n).padStart(2, '0'); }
function collectFiles(dir) {
const out = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (err) {
console.warn(`[WARN] 읽기 실패: ${dir} (${err.message})`);
return out;
}
for (const ent of entries) {
const full = path.join(dir, ent.name);
if (ent.isDirectory()) {
out.push(...collectFiles(full));
} else if (ent.isFile() && SUPPORTED_EXTS.has(path.extname(ent.name).toLowerCase())) {
out.push(full);
}
}
return out;
}
function md5OfFile(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('error', reject);
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
});
}
function exifDateToObj(dt) {
if (!dt) return null;
if (typeof dt === 'object' && typeof dt.year === 'number') {
return {
iso: `${dt.year}-${pad(dt.month ?? 1)}-${pad(dt.day ?? 1)}T` +
`${pad(dt.hour ?? 0)}:${pad(dt.minute ?? 0)}:${pad(dt.second ?? 0)}`,
year: dt.year,
raw: typeof dt.toString === 'function' ? dt.toString() : String(dt),
};
}
if (dt instanceof Date) {
return { iso: dt.toISOString(), year: dt.getUTCFullYear(), raw: dt.toISOString() };
}
const d = new Date(dt);
if (!isNaN(d.getTime())) {
return { iso: d.toISOString(), year: d.getUTCFullYear(), raw: String(dt) };
}
return null;
}
function pickTakenDate(tags) {
return (
tags?.DateTimeOriginal ??
tags?.CreateDate ??
tags?.MediaCreateDate ??
tags?.TrackCreateDate ??
tags?.ModifyDate ??
null
);
}
async function extractMeta(filePath) {
const stat = await fsp.stat(filePath);
let tags = null;
try {
tags = await exiftool.read(filePath);
} catch (err) {
// EXIF read failure is non-fatal
}
const takenRaw = pickTakenDate(tags);
const taken = exifDateToObj(takenRaw);
// Prefer EXIF year; fall back to mtime
const year = taken?.year ?? new Date(stat.mtime).getFullYear();
// Only store essential fields to keep JSON compact
const exifSummary = tags ? {
Make: tags.Make ?? null,
Model: tags.Model ?? null,
LensModel: tags.LensModel ?? null,
ImageWidth: tags.ImageWidth ?? tags.ExifImageWidth ?? null,
ImageHeight: tags.ImageHeight ?? tags.ExifImageHeight ?? null,
Orientation: tags.Orientation ?? null,
DateTimeOriginal: exifDateToObj(tags.DateTimeOriginal)?.iso ?? null,
CreateDate: exifDateToObj(tags.CreateDate)?.iso ?? null,
MediaCreateDate: exifDateToObj(tags.MediaCreateDate)?.iso ?? null,
GPSLatitude: tags.GPSLatitude ?? null,
GPSLongitude: tags.GPSLongitude ?? null,
Duration: tags.Duration ?? null,
MIMEType: tags.MIMEType ?? null,
FileType: tags.FileType ?? null,
} : null;
return {
size: stat.size,
mtime: new Date(stat.mtime).toISOString(),
ctime: new Date(stat.ctime).toISOString(),
birthtime: new Date(stat.birthtime).toISOString(),
taken: taken?.iso ?? null,
takenSrc: takenRaw ? (
tags?.DateTimeOriginal ? 'DateTimeOriginal' :
tags?.CreateDate ? 'CreateDate' :
tags?.MediaCreateDate ? 'MediaCreateDate' :
tags?.TrackCreateDate ? 'TrackCreateDate' :
'ModifyDate'
) : null,
year,
exif: exifSummary,
};
}
function loadCache(jsonPath) {
if (!fs.existsSync(jsonPath)) return new Map();
try {
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
const map = new Map();
if (Array.isArray(raw.files)) {
for (const r of raw.files) {
if (!r || !r.path || !r.md5) continue;
map.set(path.resolve(r.path).toLowerCase(), r);
}
}
return map;
} catch (err) {
console.warn(`[WARN] 캐시 로드 실패: ${jsonPath} (${err.message})`);
return new Map();
}
}
function isCacheValid(cached, stat) {
if (!cached) return false;
if (cached.size !== stat.size) return false;
const cachedMtime = Date.parse(cached.mtime);
const currMtime = stat.mtimeMs ?? new Date(stat.mtime).getTime();
if (isNaN(cachedMtime)) return false;
// Allow 1s tolerance for filesystem mtime precision
return Math.abs(cachedMtime - currMtime) < 1000;
}
async function mapWithConcurrency(items, limit, worker) {
const results = new Array(items.length);
let idx = 0;
let done = 0;
const total = items.length;
async function runOne() {
while (true) {
const i = idx++;
if (i >= total) return;
try {
results[i] = await worker(items[i], i);
} catch (err) {
const item = items[i];
const errorRecord = { __error: err.message };
if (item && typeof item === 'object') {
if (item.base) errorRecord.base = item.base;
if (item.file) {
errorRecord.path = item.file;
errorRecord.name = path.basename(item.file);
if (item.base) errorRecord.rel = path.relative(item.base, item.file);
}
}
results[i] = errorRecord;
}
done++;
if (done % 50 === 0 || done === total) {
process.stdout.write(`\r 진행: ${done}/${total}`);
}
}
}
const runners = Array.from({ length: Math.min(limit, total) }, () => runOne());
await Promise.all(runners);
if (total > 0) process.stdout.write('\n');
return results;
}
async function ensureDir(dir) {
await fsp.mkdir(dir, { recursive: true });
}
async function pickUniquePath(targetDir, baseName, reserved) {
// Append _dup1, _dup2, ... on filename collision; reserved tracks in-session allocations
const ext = path.extname(baseName);
const stem = baseName.slice(0, baseName.length - ext.length);
let candidate = path.join(targetDir, baseName);
let n = 1;
while ((reserved && reserved.has(candidate.toLowerCase())) || fs.existsSync(candidate)) {
candidate = path.join(targetDir, `${stem}_dup${n}${ext}`);
n++;
}
if (reserved) reserved.add(candidate.toLowerCase());
return candidate;
}
async function resolveTargetPath(targetDir, baseName, reserved, mode) {
const direct = path.join(targetDir, baseName);
const directKey = direct.toLowerCase();
if (mode === 'overwrite') {
if (reserved) reserved.add(directKey);
return { dst: direct, skip: false };
}
if (mode === 'ignore') {
const exists = (reserved && reserved.has(directKey)) || fs.existsSync(direct);
if (reserved) reserved.add(directKey);
return { dst: direct, skip: exists };
}
const dst = await pickUniquePath(targetDir, baseName, reserved);
return { dst, skip: false };
}
async function transferFile(src, dst, dryRun, allowOverwrite) {
// Always copy only; source deletion is handled separately in safeDeleteSource()
if (dryRun) return;
await ensureDir(path.dirname(dst));
if (allowOverwrite) {
await fsp.copyFile(src, dst);
} else {
// Prevent accidental overwrite
await fsp.copyFile(src, dst, fs.constants.COPYFILE_EXCL);
}
}
async function safeDeleteSource(srcPath, expectedSize, expectedMd5, dstPath, dryRun) {
if (dryRun) return { deleted: false, reason: 'dry-run' };
if (!expectedMd5) return { deleted: false, reason: 'missing expected md5' };
try {
const dstStat = await fsp.stat(dstPath);
if (!dstStat.isFile()) {
return { deleted: false, reason: 'dst is not a file' };
}
if (dstStat.size !== expectedSize) {
return { deleted: false, reason: `size mismatch (src=${expectedSize}, dst=${dstStat.size})` };
}
} catch (err) {
return { deleted: false, reason: `dst stat failed: ${err.message}` };
}
// Safety: never delete if src and dst resolve to the same path
if (path.resolve(srcPath).toLowerCase() === path.resolve(dstPath).toLowerCase()) {
return { deleted: false, reason: 'src and dst are same path' };
}
try {
const [srcMd5, dstMd5] = await Promise.all([
md5OfFile(srcPath),
md5OfFile(dstPath),
]);
if (srcMd5 !== expectedMd5) {
return { deleted: false, reason: `src md5 mismatch (expected=${expectedMd5}, actual=${srcMd5})` };
}
if (dstMd5 !== expectedMd5) {
return { deleted: false, reason: `dst md5 mismatch (expected=${expectedMd5}, actual=${dstMd5})` };
}
} catch (err) {
return { deleted: false, reason: `md5 verify failed: ${err.message}` };
}
try {
await fsp.unlink(srcPath);
return { deleted: true };
} catch (err) {
return { deleted: false, reason: `unlink failed: ${err.message}` };
}
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
console.log('═'.repeat(60));
console.log(' merge_by_year.js');
console.log('═'.repeat(60));
console.log(` out : ${opts.out}`);
console.log(` json : ${opts.json}`);
console.log(` mode : ${opts.mode}${opts.dryRun ? ' [DRY-RUN]' : ''}`);
console.log(` on-conflict : ${opts.onConflict}`);
console.log(` concurrency : ${opts.concurrency}`);
console.log(` bases :`);
for (const b of opts.bases) console.log(` - ${b}`);
console.log('─'.repeat(60));
for (const b of opts.bases) {
if (!fs.existsSync(b)) {
console.error(`[ERROR] base 폴더가 존재하지 않습니다: ${b}`);
process.exit(1);
}
}
// 1) Collect files
const allFiles = [];
for (const b of opts.bases) {
const files = collectFiles(b);
console.log(` 수집: ${files.length}개 ← ${b}`);
for (const f of files) allFiles.push({ base: b, file: f });
}
console.log(` 전체: ${allFiles.length}개`);
console.log('─'.repeat(60));
if (allFiles.length === 0) {
console.log('처리할 파일이 없습니다.');
return;
}
// 2) Extract metadata + MD5 (reuse cache from existing index JSON if available)
const cache = opts.noCache ? new Map() : loadCache(opts.json);
if (cache.size > 0) {
console.log(`캐시 로드: ${cache.size}건 (${opts.json})`);
} else if (!opts.noCache) {
console.log(`캐시 없음 (신규 생성)`);
}
let cacheHits = 0;
let cacheMisses = 0;
console.log('메타데이터 / MD5 추출 중...');
const records = await mapWithConcurrency(allFiles, opts.concurrency, async ({ base, file }) => {
const key = path.resolve(file).toLowerCase();
const cached = cache.get(key);
let stat;
try { stat = await fsp.stat(file); } catch { stat = null; }
if (stat && isCacheValid(cached, stat)) {
cacheHits++;
return {
...cached,
base,
path: file,
rel: path.relative(base, file),
name: path.basename(file),
cached: true,
};
}
cacheMisses++;
const [meta, md5] = await Promise.all([
extractMeta(file),
md5OfFile(file),
]);
return {
base,
path: file,
rel: path.relative(base, file),
name: path.basename(file),
md5,
...meta,
};
});
if (cache.size > 0) {
console.log(`캐시 hit ${cacheHits} / miss ${cacheMisses}`);
}
const errors = records.filter(r => r && r.__error);
const ok = records.filter(r => r && !r.__error);
if (errors.length) {
console.warn(`[WARN] 처리 실패 ${errors.length}건`);
}
// 3) Deduplicate by MD5
const byMd5 = new Map();
for (const r of ok) {
if (!byMd5.has(r.md5)) byMd5.set(r.md5, []);
byMd5.get(r.md5).push(r);
}
// Secondary check: group by size + taken + name to detect hash-differing apparent duplicates
const bySecondary = new Map();
for (const r of ok) {
const key = `${r.size}|${r.taken ?? ''}|${r.name.toLowerCase()}`;
if (!bySecondary.has(key)) bySecondary.set(key, []);
bySecondary.get(key).push(r);
}
const suspiciousDups = [];
for (const [key, grp] of bySecondary) {
if (grp.length < 2) continue;
const uniqueMd5 = new Set(grp.map(g => g.md5));
if (uniqueMd5.size > 1) {
// Same apparent file but different hashes — likely metadata-only edit
suspiciousDups.push({
key,
items: grp.map(g => ({ path: g.path, md5: g.md5, size: g.size, taken: g.taken })),
});
}
}
// 4) Save index JSON
await ensureDir(path.dirname(opts.json));
const index = {
generatedAt: new Date().toISOString(),
options: {
out: opts.out,
bases: opts.bases,
mode: opts.mode,
onConflict: opts.onConflict,
deleteSource: opts.deleteSource,
dryRun: opts.dryRun,
},
summary: {
totalFiles: allFiles.length,
processed: ok.length,
errors: errors.length,
uniqueByMd5: byMd5.size,
duplicateGroups: [...byMd5.values()].filter(g => g.length > 1).length,
suspiciousGroups: suspiciousDups.length,
},
files: ok,
errors,
suspiciousDuplicates: suspiciousDups,
};
await fsp.writeFile(opts.json, JSON.stringify(index, null, 2), 'utf8');
console.log(`인덱스 저장: ${opts.json}`);
console.log(` 처리 ${ok.length}, 오류 ${errors.length}, 고유 해시 ${byMd5.size}, 중복 그룹 ${index.summary.duplicateGroups}, 의심 그룹 ${suspiciousDups.length}`);
if (opts.noMerge) {
console.log('--no-merge 지정: 머지 단계 건너뜀.');
return;
}
// 5) Merge: copy/move one representative per MD5 group
console.log('─'.repeat(60));
console.log(`머지 시작${opts.dryRun ? ' [DRY-RUN]' : ''}...`);
const yearCount = new Map();
let merged = 0, skipped = 0, failed = 0;
let deletedCount = 0, keptCount = 0;
const deleteWarnings = [];
// Pick best representative: prefer EXIF date present > earlier base index > shorter path
function chooseRepresentative(group) {
return [...group].sort((a, b) => {
const at = a.taken ? 0 : 1;
const bt = b.taken ? 0 : 1;
if (at !== bt) return at - bt;
const ai = opts.bases.indexOf(a.base);
const bi = opts.bases.indexOf(b.base);
if (ai !== bi) return ai - bi;
return a.path.length - b.path.length;
})[0];
}
// Phase 1: pre-calculate all target paths (sequential, in-memory reservation)
console.log(`Phase 1: 대상 경로 계산 (${byMd5.size} 그룹)...`);
const reserved = new Set();
const plans = [];
for (const [md5, group] of byMd5) {
const rep = chooseRepresentative(group);
const year = String(rep.year ?? 'UNKNOWN');
const targetDir = path.join(opts.out, year);
if (!opts.dryRun) await ensureDir(targetDir);
const { dst, skip } = await resolveTargetPath(targetDir, rep.name, reserved, opts.onConflict);
plans.push({ md5, group, rep, year, dst, skip });
}
// Phase 2: parallel copy
console.log(`Phase 2: 파일 복사 (병렬 ${opts.concurrency})...`);
const copyResults = await mapWithConcurrency(plans, opts.concurrency, async (plan) => {
if (plan.skip) return { ok: true, plan, ignored: true };
try {
await transferFile(plan.rep.path, plan.dst, opts.dryRun, opts.onConflict === 'overwrite');
return { ok: true, plan };
} catch (err) {
return { ok: false, plan, error: err.message };
}
});
let ignoredCount = 0;
for (const r of copyResults) {
const { plan } = r;
const relDst = path.relative(opts.out, plan.dst);
if (r.ok) {
if (r.ignored) {
ignoredCount++;
console.log(`[IGNORE ] ${plan.rep.rel} → ${relDst} (이미 존재)`);
continue;
}
merged++;
yearCount.set(plan.year, (yearCount.get(plan.year) ?? 0) + 1);
if (plan.group.length > 1) {
const others = plan.group.filter(g => g !== plan.rep);
const tag = opts.onConflict === 'overwrite' ? 'DUP/OVR' : 'DUP ';
console.log(`[${tag} x${plan.group.length}] ${plan.rep.rel} → ${relDst}`);
for (const o of others) console.log(` dup : ${o.path}`);
skipped += others.length;
} else {
const tag = opts.onConflict === 'overwrite' ? 'OVR ' : 'OK ';
console.log(`[${tag}] ${plan.rep.rel} → ${relDst}`);
}
} else {
console.error(`[FAIL ] ${plan.rep.path} (${r.error})`);
failed++;
}
}
// Phase 3: delete source files (parallel, only from successful copy groups)
if (opts.deleteSource) {
console.log('─'.repeat(60));
console.log(`Phase 3: 원본 삭제 (성공 그룹만, 병렬 ${opts.concurrency})...`);
const deleteTasks = [];
for (const r of copyResults) {
if (!r.ok || r.ignored) continue;
for (const g of r.plan.group) {
// MD5 equality guaranteed; verify against dst size
deleteTasks.push({ src: g.path, size: g.size, md5: g.md5, dst: r.plan.dst });
}
}
const delResults = await mapWithConcurrency(deleteTasks, opts.concurrency, async (t) => {
return await safeDeleteSource(t.src, t.size, t.md5, t.dst, opts.dryRun);
});
for (let i = 0; i < delResults.length; i++) {
const res = delResults[i];
const t = deleteTasks[i];
if (res && res.deleted) {
deletedCount++;
} else {
keptCount++;
const reason = res?.reason ?? res?.__error ?? 'unknown';
if (reason !== 'dry-run') {
console.warn(`[KEEP ] ${t.src} (${reason})`);
deleteWarnings.push({ path: t.src, reason });
}
}
}
}
// 6) Summary
console.log('═'.repeat(60));
console.log(`머지 완료: ${merged}개 (중복 건너뜀: ${skipped}, 실패: ${failed}${ignoredCount ? `, 기존파일 무시: ${ignoredCount}` : ''})`);
if (opts.deleteSource) {
console.log(`원본 삭제: ${deletedCount}개 (보존: ${keptCount}개${errors.length ? `, 추출실패 ${errors.length}개는 손대지 않음` : ''})`);
if (deleteWarnings.length) {
console.log(` [!] 검증 실패로 보존된 파일 ${deleteWarnings.length}건은 인덱스 JSON 의 deleteWarnings 참고`);
}
}
const years = [...yearCount.keys()].sort();
for (const y of years) {
console.log(` ${y}: ${yearCount.get(y)}개`);
}
if (suspiciousDups.length) {
console.log('─'.repeat(60));
console.log(`[!] 해시는 다르지만 size+촬영시각+이름이 동일한 의심 그룹 ${suspiciousDups.length}개 발견.`);
console.log(` 상세는 ${opts.json} 의 suspiciousDuplicates 항목 참고.`);
}
if (opts.dryRun) console.log('(DRY-RUN: 실제 파일은 변경되지 않았습니다)');
console.log('═'.repeat(60));
// 7) Update index JSON with merge results
if (!opts.dryRun) {
index.mergeResult = {
merged,
skipped,
failed,
ignored: ignoredCount,
deleted: deletedCount,
kept: keptCount,
deleteWarnings,
};
try {
await fsp.writeFile(opts.json, JSON.stringify(index, null, 2), 'utf8');
} catch (err) {
console.warn(`[WARN] 인덱스 재저장 실패: ${err.message}`);
}
}
}
main()
.catch(err => {
console.error('[FATAL]', err);
process.exit(1);
})
.finally(() => exiftool.end());
