Categories
Scripts

사진/동영상 파일명 촬영일 기준 일괄 변경 스크립트(Node.js)

아이폰에서 추출한 사진 이름 앞에 날짜시간을 붙이는 스크립트입니다. 이미지 또는 동영상 파일 이름에 아직 날짜시간이 없는 전재로 합니다.

사진에서 EXIF 값 추출해서 촬영일 기준으로 수정합니다. 예시는 다음과 같습니다.

스크립트 실행 완료 후 출력은 다음과 같습니다.

실제 윈도우 탐색기에서 확인하면 다음과 같이 수정되어 있습니다.

Node.js v22/24 LTS 기준

npm install exiftool-vendored

파일명에서 날짜 형식은 formatDateTime() 함수 수정하면 됩니다

/**
 * rename_by_date.js
 *
 * ./target 폴더의 이미지/동영상 파일을 아래 형식으로 일괄 이름 변경:
 *   YYYYMMDD_hhmmss_원본파일이름.ext
 *
 * 날짜 우선순위:
 *   1. EXIF DateTimeOriginal
 *   2. EXIF CreateDate / MediaCreateDate
 *   3. 파일 수정일 (mtime)
 *
 * 사용법:
 *   node scripts/rename_by_date.js [경로]             # 실제 변경 (기본값: ./target)
 *   node scripts/rename_by_date.js [경로] --dry-run   # 변경 내용만 출력 (실제 변경 없음)
 *
 * 예:
 *   node scripts/rename_by_date.js
 *   node scripts/rename_by_date.js D:\Photos\2024
 *   node scripts/rename_by_date.js "C:\My Photos" --dry-run
 */

'use strict';

const fs   = require('fs');
const path = require('path');
const { exiftool } = require('exiftool-vendored');

// ── 설정 ──────────────────────────────────────────────────────────────────────

const args       = process.argv.slice(2).filter(a => a !== '--dry-run');
const TARGET_DIR = args[0] ? path.resolve(args[0]) : path.resolve(__dirname, '..', 'target');
const DRY_RUN    = process.argv.includes('--dry-run');

const SUPPORTED_EXTS  = new Set([
  '.jpg', '.jpeg', '.heic', '.heif', '.png', '.gif', '.tiff', '.tif',
  '.mov', '.mp4', '.m4v', '.avi', '.3gp', '.mkv'
]);
const ALREADY_RENAMED = /^\d{8}_\d{6}_/;

// ── 날짜 포맷 ─────────────────────────────────────────────────────────────────

const pad = n => String(n).padStart(2, '0');

function formatDateTime(dt) {
  if (dt && typeof dt === 'object' && typeof dt.year === 'number') {
    const { year: y, month = 1, day = 1, hour = 0, minute = 0, second = 0 } = dt;
    return `${y}${pad(month)}${pad(day)}_${pad(hour)}${pad(minute)}${pad(second)}`;
  }
  const d = dt instanceof Date ? dt : new Date(dt);
  return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}

// ── EXIF 날짜 읽기 ────────────────────────────────────────────────────────────

async function readTakenDate(filePath) {
  try {
    const tags = await exiftool.read(filePath);
    return tags.DateTimeOriginal ?? tags.CreateDate ?? tags.MediaCreateDate ?? tags.TrackCreateDate ?? null;
  } catch { return null; }
}

// ── 메인 ─────────────────────────────────────────────────────────────────────

function collectFiles(dir) {
  return fs.readdirSync(dir).flatMap(name => {
    const full = path.join(dir, name);
    return fs.statSync(full).isDirectory()
      ? collectFiles(full)
      : SUPPORTED_EXTS.has(path.extname(name).toLowerCase()) ? [full] : [];
  });
}

async function main() {
  if (!fs.existsSync(TARGET_DIR)) {
    console.error(`[ERROR] 대상 폴더를 찾을 수 없습니다: ${TARGET_DIR}`);
    process.exit(1);
  }

  const files = collectFiles(TARGET_DIR);
  if (files.length === 0) { console.log('처리할 파일이 없습니다.'); return; }

  console.log(`대상 파일 수: ${files.length}${DRY_RUN ? '  [DRY-RUN 모드]' : ''}`);
  console.log('─'.repeat(60));

  let renamed = 0, skipped = 0, conflict = 0;
  const statYear = new Map(), statMonth = new Map(), statDay = new Map();

  function recordStat(prefix) {
    const y = prefix.slice(0, 4), m = prefix.slice(4, 6), d = prefix.slice(6, 8);
    const ym = `${y}-${m}`, ymd = `${y}-${m}-${d}`;
    statYear .set(y,   (statYear .get(y)   ?? 0) + 1);
    statMonth.set(ym,  (statMonth.get(ym)  ?? 0) + 1);
    statDay  .set(ymd, (statDay  .get(ymd) ?? 0) + 1);
  }

  for (const filePath of files) {
    const baseName = path.basename(filePath);
    const relPath  = path.relative(TARGET_DIR, filePath);

    if (ALREADY_RENAMED.test(baseName)) {
      console.log(`[SKIP   ] ${relPath}`);
      skipped++; recordStat(baseName.slice(0, 15));
      continue;
    }

    const exifDt  = await readTakenDate(filePath);
    const prefix  = formatDateTime(exifDt ?? fs.statSync(filePath).mtime);
    const source  = exifDt ? 'EXIF' : 'MTIME';
    const newName = `${prefix}_${baseName}`;
    const newPath = path.join(path.dirname(filePath), newName);
    const relNew  = path.relative(TARGET_DIR, newPath);

    if (fs.existsSync(newPath)) {
      console.log(`[CONFLICT] ${relPath}  →  ${relNew}  (이미 존재)`);
      conflict++; recordStat(prefix);
      continue;
    }

    console.log(`[${source.padEnd(5)}] ${relPath}  →  ${relNew}`);
    if (!DRY_RUN) fs.renameSync(filePath, newPath);
    renamed++; recordStat(prefix);
  }

  console.log('─'.repeat(60));
  console.log(`완료: 변경 ${renamed}개, 건너뜀 ${skipped}개, 충돌 ${conflict}개`);
  if (DRY_RUN) console.log('(DRY-RUN: 실제 파일은 변경되지 않았습니다)');

  // ── 통계 출력 ───────────────────────────────────────────────────────────────
  console.log('\n' + '═'.repeat(60));
  console.log('  통계 요약');
  console.log('═'.repeat(60));

  for (const y of [...statYear.keys()].sort()) {
    console.log(`\n  📅 ${y}년  (${statYear.get(y)}개)`);
    for (const ym of [...statMonth.keys()].filter(k => k.startsWith(y)).sort()) {
      const [, m] = ym.split('-');
      console.log(`    ${m}월  (${statMonth.get(ym)}개)`);
      for (const ymd of [...statDay.keys()].filter(k => k.startsWith(ym)).sort()) {
        const [, , dd] = ymd.split('-');
        console.log(`      ${dd}일  ${statDay.get(ymd)}개`);
      }
    }
  }
  console.log('\n' + '═'.repeat(60));
}

main()
  .catch(err => { console.error('[FATAL]', err); process.exit(1); })
  .finally(() => exiftool.end());

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.