Last active
July 7, 2024 07:25
-
-
Save lijunle/d714c8f4b2554c3e4acfa866605645e8 to your computer and use it in GitHub Desktop.
Rename DJI and iOS video files to normalized format.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.DS_Store | |
node_modules |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
// @ts-check | |
import fs from "node:fs"; | |
import path from "node:path"; | |
import process from "node:process"; | |
import url from "node:url"; | |
import { ExifDateTime, exiftool } from "exiftool-vendored"; | |
/** | |
* Main function. | |
* @param {string[]} argv Process arguments. | |
* @returns {Promise<number>} The exit code. | |
*/ | |
async function main(argv) { | |
if (!argv[2]) { | |
console.error( | |
`Usage: node ${argv[1]} <media-folder> [--run] [--timezone=Asia/Shanghai]` | |
); | |
return -1; | |
} | |
// Get media folder. | |
const dirname = path.dirname(url.fileURLToPath(import.meta.url)); | |
const folder = path.resolve(dirname, argv[2]); | |
console.info("Media folder: " + folder); | |
// Default to dry run mode. | |
const dryRun = !argv.includes("--run"); | |
console.info(`Dry run mode: ${dryRun}`); | |
// Set time zone name. | |
const timeZone = argv | |
.find((arg) => arg.startsWith("--timezone=")) | |
?.substring(11); | |
console.info(`Time zone: ${timeZone}`); | |
const mediaFolder = new MediaFolder(folder, dryRun); | |
for (let filename of mediaFolder.getFiles()) { | |
await processExifMediaFiles(filename, mediaFolder, timeZone); | |
processDjiVideoFiles(filename, mediaFolder); | |
} | |
exiftool.end(); | |
return 0; | |
} | |
/** | |
* Process EXIF based media files. | |
* It can be Sony photo and video files, or iPhone photo and video files. | |
* @param {string} filename File name. | |
* @param {MediaFolder} mediaFolder Media folder. | |
* @param {string | undefined} timeZone Time zone name. | |
*/ | |
async function processExifMediaFiles(filename, mediaFolder, timeZone) { | |
const extname = path.extname(filename).toLowerCase(); | |
if ( | |
(filename.startsWith("_DSC") && extname === ".arw") || // Sony photo | |
(filename.startsWith("C") && extname === ".mp4") || // Sony video | |
(filename.startsWith("IMG_") && [".mov", ".heic", ".jpg"].includes(extname)) // iPhone photo and video | |
) { | |
const createDate = await mediaFolder.getExifCreateDate(filename); | |
const targetFilename = formatDate(createDate, timeZone) + extname; | |
mediaFolder.renameSafely("EXIF", filename, targetFilename); | |
} | |
} | |
/** | |
* Process DJI video files. | |
* @param {string} filename File name. | |
* @param {MediaFolder} mediaFolder Media folder. | |
*/ | |
function processDjiVideoFiles(filename, mediaFolder) { | |
if (filename.startsWith("DJI_")) { | |
const { name, ext } = path.parse(filename); | |
const targetName = name.replace( | |
/DJI_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})_\d{3}_video/, | |
"$1-$2-$3 $4.$5.$6" | |
); | |
const targetedFilename = targetName + ext.toLowerCase(); | |
mediaFolder.renameSafely("DJI", filename, targetedFilename); | |
} | |
} | |
/** | |
* Format the date time to file name format. | |
* @param {Date} date The date time to format | |
* @param {string | undefined} timeZone Time zone name. | |
* @returns The formated date time string | |
*/ | |
function formatDate(date, timeZone) { | |
/** @type {Intl.DateTimeFormatOptions} */ | |
const options = { | |
timeZone, | |
hour12: false, | |
year: "numeric", | |
month: "2-digit", | |
day: "2-digit", | |
hour: "2-digit", | |
minute: "2-digit", | |
second: "2-digit", | |
}; | |
const str = date.toLocaleString("en-US", options); | |
const [month, day, year, hour, minute, second] = str.split(/[\s,:/]+/); | |
const output = `${year}-${month}-${day} ${hour}.${minute}.${second}`; | |
return output; | |
} | |
class MediaFolder { | |
/** | |
* Create a new instance of MediaFolder. | |
* @param {string} folder The media folder. | |
* @param {boolean} dryRun Dry run mode. | |
*/ | |
constructor(folder, dryRun) { | |
this.folder = folder; | |
this.dryRun = dryRun; | |
// New file names is only used in dry run mode to detect naming conflicts. | |
this.newFilenames = new Set(this.dryRun ? this.getFiles() : []); | |
} | |
/** | |
* Read the files in the media folder. | |
* @returns {string[]} The files in the folder. | |
*/ | |
getFiles() { | |
const files = fs.readdirSync(this.folder, "utf-8"); | |
return files; | |
} | |
/** | |
* Get the EXIF create date from the file. | |
* @param {string} filename The file name | |
* @returns {Promise<Date>} The create date. | |
*/ | |
async getExifCreateDate(filename) { | |
const filePath = path.resolve(this.folder, filename); | |
const tags = await exiftool.read(filePath, ["-api", "LargeFileSupport=1"]); | |
const date = tags.CreationDate || tags.CreateDate; | |
if (!date) { | |
console.warn(`Create date not found: ${filename}`); | |
throw new Error("Create date not found"); | |
} else if (typeof date === "string") { | |
const offsetTime = tags.OffsetTime || "Z"; | |
const createDate = new Date(date + offsetTime); | |
return createDate; | |
} else { | |
const createDate = this.#extractExifDateTime(date).toDate(); | |
return createDate; | |
} | |
} | |
/** | |
* Extract the EXIF date time. | |
* @param {ExifDateTime | { Value: ExifDateTime }} date EXIF date time or its wrapper. | |
* @returns {ExifDateTime} The EXIF date time. | |
*/ | |
#extractExifDateTime(date) { | |
// It seems a bug in some platforms that the ExifDateTime is wrapped in an object. | |
if (date instanceof ExifDateTime) { | |
return date; | |
} else { | |
return date.Value; | |
} | |
} | |
/** | |
* Resolve the file name with suffix. | |
* @param {string} filename File name. | |
* @param {string | number} suffix File name suffix. | |
* @returns {string} The resolved file name. | |
*/ | |
#resolveName(filename, suffix = "") { | |
if (suffix) { | |
const extname = path.extname(filename); | |
const basename = path.basename(filename, extname); | |
const name = `${basename} ${suffix}${extname}`; | |
return name; | |
} else { | |
return filename; | |
} | |
} | |
/** | |
* Check if the file exists. | |
* @param {string} filename The file name. | |
* @returns {boolean} True if the file exists. | |
*/ | |
#fileExists(filename) { | |
if (this.dryRun) { | |
return this.newFilenames.has(filename); | |
} else { | |
const filePath = path.resolve(this.folder, filename); | |
return fs.existsSync(filePath); | |
} | |
} | |
/** | |
* Rename the old file name to new one safely. | |
* This method will detect naming conflict and resolve it with counter suffix. | |
* This method will detect darktable XMP and Sony Video XML files and rename them as well. | |
* @param {string} category Category of the file. | |
* @param {string} oldFilename Old file name. | |
* @param {string} newFilename New file name. | |
* @param {number} suffix Counter suffix to resolve file name conflict issue. | |
*/ | |
renameSafely(category, oldFilename, newFilename, suffix = 0) { | |
const oldName = this.#resolveName(oldFilename); | |
const newName = this.#resolveName(newFilename, suffix); | |
if (this.#fileExists(newName)) { | |
this.renameSafely(category, oldFilename, newFilename, suffix + 1); | |
} else { | |
this.#renameFile(category, oldName, newName); | |
// If darktable XMP file exists, rename it as well. | |
const xmpOldName = `${oldName}.xmp`; | |
if (this.#fileExists(xmpOldName)) { | |
const xmpNewName = `${newName}.xmp`; | |
this.#renameFile("Darktable XMP", xmpOldName, xmpNewName); | |
} | |
// If Sony Video XML file exists, rename it as well. | |
const xmlOldName = oldName.replace(/\.MP4$/, "M01.XML"); | |
if (oldName.endsWith(".MP4") && this.#fileExists(xmlOldName)) { | |
const xmlNewName = newName.replace(/\.mp4$/, ".xml"); | |
this.#renameFile("Sony Video XML", xmlOldName, xmlNewName); | |
} | |
} | |
} | |
/** | |
* Rename the file. It must ensure the new file name does not conflict. | |
* @param {string} category The category of the file. | |
* @param {string} oldFilename The old file name. | |
* @param {string} newFilename The new file name. | |
*/ | |
#renameFile(category, oldFilename, newFilename) { | |
process.stdout.write( | |
`${category.padEnd(16)}${newFilename.padEnd(27)}${oldFilename}` | |
); | |
if (this.dryRun) { | |
this.newFilenames.add(newFilename); | |
process.stdout.write("\t\u{1F7E1}\n"); | |
} else { | |
const oldPath = path.resolve(this.folder, oldFilename); | |
const newPath = path.resolve(this.folder, newFilename); | |
fs.renameSync(oldPath, newPath); | |
process.stdout.write("\t\u{1F7E2}\n"); | |
} | |
} | |
} | |
process.exit(await main(process.argv)); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"name": "media-renames", | |
"version": "0.0.1", | |
"lockfileVersion": 3, | |
"requires": true, | |
"packages": { | |
"": { | |
"name": "media-renames", | |
"version": "0.0.1", | |
"dependencies": { | |
"exiftool-vendored": "^26.2.0" | |
} | |
}, | |
"node_modules/@photostructure/tz-lookup": { | |
"version": "10.0.0", | |
"resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-10.0.0.tgz", | |
"integrity": "sha512-8ZAjoj/irCuvUlyEinQ/HB6A8hP3bD1dgTOZvfl1b9nAwqniutFDHOQRcGM6Crea68bOwPj010f0Z4KkmuLHEA==" | |
}, | |
"node_modules/@types/luxon": { | |
"version": "3.4.2", | |
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", | |
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==" | |
}, | |
"node_modules/batch-cluster": { | |
"version": "13.0.0", | |
"resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-13.0.0.tgz", | |
"integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og==", | |
"engines": { | |
"node": ">=14" | |
} | |
}, | |
"node_modules/exiftool-vendored": { | |
"version": "26.2.0", | |
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-26.2.0.tgz", | |
"integrity": "sha512-7P6jQ944or7ic2SJzW+uaWK4TLDXlaCppHrBayl4MpIrVcEeQjiQTez4/oOH0wULIRu4j4H6Xruz4SLrDaafUg==", | |
"dependencies": { | |
"@photostructure/tz-lookup": "^10.0.0", | |
"@types/luxon": "^3.4.2", | |
"batch-cluster": "^13.0.0", | |
"he": "^1.2.0", | |
"luxon": "^3.4.4" | |
}, | |
"optionalDependencies": { | |
"exiftool-vendored.exe": "12.85.0", | |
"exiftool-vendored.pl": "12.85.0" | |
} | |
}, | |
"node_modules/exiftool-vendored.exe": { | |
"version": "12.85.0", | |
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.85.0.tgz", | |
"integrity": "sha512-rWsKVp9oXsS79S3bfCNXKeEo4av0xcd7slk/TfPpCa5pojg8ZVXSVfPZMAAlhOuK63YXrKN/e3jRNReeGP+2Gw==", | |
"optional": true, | |
"os": [ | |
"win32" | |
] | |
}, | |
"node_modules/exiftool-vendored.pl": { | |
"version": "12.85.0", | |
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.85.0.tgz", | |
"integrity": "sha512-AelZQCCfl0a0g7PYx90TqbNGlSu2zDbRfCTjGw6bBBYnJF0NUfUWVhTpa8XGe2lHx1KYikH8AkJaey3esAxMAg==", | |
"optional": true, | |
"os": [ | |
"!win32" | |
] | |
}, | |
"node_modules/he": { | |
"version": "1.2.0", | |
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", | |
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", | |
"bin": { | |
"he": "bin/he" | |
} | |
}, | |
"node_modules/luxon": { | |
"version": "3.4.4", | |
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", | |
"integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", | |
"engines": { | |
"node": ">=12" | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"name": "media-renames", | |
"version": "0.0.1", | |
"private": true, | |
"dependencies": { | |
"exiftool-vendored": "^26.2.0" | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment