294 lines
13 KiB
JavaScript
Executable file
294 lines
13 KiB
JavaScript
Executable file
/**
|
||
* Matrix Cycling Blog - Image Processor & Comprehensive EXIF Engine
|
||
* Automatically extracts Camera, Lens, Aperture, Shutter Speed, ISO, DateTime, and GPS from photos.
|
||
*/
|
||
|
||
window.MatrixImageProcessor = (function () {
|
||
/**
|
||
* Resizes and compresses an image file to prevent huge local storage usage.
|
||
*/
|
||
function compressImage(file, maxWidth = 2048, maxHeight = 1536, quality = 0.85) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = function (e) {
|
||
const img = new Image();
|
||
img.onload = function () {
|
||
let w = img.width;
|
||
let h = img.height;
|
||
|
||
if (w > maxWidth || h > maxHeight) {
|
||
const ratio = Math.min(maxWidth / w, maxHeight / h);
|
||
w = Math.round(w * ratio);
|
||
h = Math.round(h * ratio);
|
||
}
|
||
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = w;
|
||
canvas.height = h;
|
||
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.drawImage(img, 0, 0, w, h);
|
||
|
||
let dataUrl = canvas.toDataURL("image/webp", quality);
|
||
if (!dataUrl.startsWith("data:image/webp")) {
|
||
dataUrl = canvas.toDataURL("image/jpeg", quality);
|
||
}
|
||
|
||
resolve({
|
||
dataUrl: dataUrl,
|
||
width: w,
|
||
height: h,
|
||
size: Math.round((dataUrl.length * 3) / 4)
|
||
});
|
||
};
|
||
img.onerror = () => reject(new Error("Не удалось загрузить изображение"));
|
||
img.src = e.target.result;
|
||
};
|
||
reader.onerror = () => reject(new Error("Не удалось прочитать файл"));
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Helper to read TIFF / EXIF tag values with endianness support.
|
||
*/
|
||
function readTagValue(view, tiffOffset, entryOffset, isLittleEndian) {
|
||
const type = view.getUint16(entryOffset + 2, isLittleEndian);
|
||
const count = view.getUint32(entryOffset + 4, isLittleEndian);
|
||
const valueOffset = entryOffset + 8;
|
||
|
||
// Type 2: ASCII string
|
||
if (type === 2) {
|
||
const offset = count > 4 ? tiffOffset + view.getUint32(valueOffset, isLittleEndian) : valueOffset;
|
||
let str = "";
|
||
for (let i = 0; i < count - 1; i++) {
|
||
const charCode = view.getUint8(offset + i);
|
||
if (charCode === 0) break;
|
||
str += String.fromCharCode(charCode);
|
||
}
|
||
return str.trim();
|
||
}
|
||
|
||
// Type 3: SHORT (unsigned 16-bit)
|
||
if (type === 3) {
|
||
return view.getUint16(valueOffset, isLittleEndian);
|
||
}
|
||
|
||
// Type 4: LONG (unsigned 32-bit)
|
||
if (type === 4) {
|
||
return view.getUint32(valueOffset, isLittleEndian);
|
||
}
|
||
|
||
// Type 5: RATIONAL (unsigned 64-bit: two LONGs - numerator & denominator)
|
||
if (type === 5) {
|
||
const offset = tiffOffset + view.getUint32(valueOffset, isLittleEndian);
|
||
if (offset + 8 <= view.byteLength) {
|
||
const num = view.getUint32(offset, isLittleEndian);
|
||
const den = view.getUint32(offset + 4, isLittleEndian);
|
||
return den !== 0 ? num / den : num;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Type 10: SRATIONAL (signed 64-bit: two SLONGs)
|
||
if (type === 10) {
|
||
const offset = tiffOffset + view.getUint32(valueOffset, isLittleEndian);
|
||
if (offset + 8 <= view.byteLength) {
|
||
const num = view.getInt32(offset, isLittleEndian);
|
||
const den = view.getInt32(offset + 4, isLittleEndian);
|
||
return den !== 0 ? num / den : num;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Reads comprehensive EXIF data from JPEG, RAW (DNG), or TIFF files.
|
||
* @param {File} file
|
||
* @returns {Promise<Object>}
|
||
*/
|
||
function extractExif(file) {
|
||
return new Promise((resolve) => {
|
||
const reader = new FileReader();
|
||
reader.onload = function (e) {
|
||
const buffer = e.target.result;
|
||
const view = new DataView(buffer);
|
||
|
||
const result = {
|
||
hasExif: false,
|
||
camera: "Mobile Camera",
|
||
make: "",
|
||
model: "",
|
||
focalLength: "24mm",
|
||
focalLength35mm: "",
|
||
aperture: "f/1.8",
|
||
shutterSpeed: "1/120s",
|
||
iso: "ISO 100",
|
||
captureEngine: "Mobile RAW",
|
||
dateTime: "",
|
||
location: "Ульяновск, Волга",
|
||
badge: "RAW DNG"
|
||
};
|
||
|
||
try {
|
||
let tiffOffset = -1;
|
||
let isLittleEndian = false;
|
||
|
||
// 1. Check JPEG SOI (0xFFD8)
|
||
if (view.getUint16(0, false) === 0xFFD8) {
|
||
let offset = 2;
|
||
const len = view.byteLength;
|
||
|
||
while (offset < len - 4) {
|
||
const marker = view.getUint16(offset, false);
|
||
if (marker === 0xFFE1) { // APP1 EXIF
|
||
const exifHeader = view.getUint32(offset + 4, false);
|
||
if (exifHeader === 0x45786966) { // "Exif\0\0"
|
||
tiffOffset = offset + 10;
|
||
break;
|
||
}
|
||
}
|
||
const markerLen = view.getUint16(offset + 2, false);
|
||
offset += 2 + markerLen;
|
||
}
|
||
}
|
||
// 2. Check TIFF / DNG Header (0x4949 or 0x4D4D)
|
||
else if (view.getUint16(0, false) === 0x4949 || view.getUint16(0, false) === 0x4D4D) {
|
||
tiffOffset = 0;
|
||
}
|
||
|
||
if (tiffOffset >= 0 && tiffOffset + 8 <= view.byteLength) {
|
||
isLittleEndian = view.getUint16(tiffOffset, false) === 0x4949;
|
||
const firstIfdOffset = tiffOffset + view.getUint32(tiffOffset + 4, isLittleEndian);
|
||
|
||
let exifSubIfdOffset = null;
|
||
let gpsIfdOffset = null;
|
||
|
||
// Parse IFD0
|
||
if (firstIfdOffset + 2 <= view.byteLength) {
|
||
const numEntries = view.getUint16(firstIfdOffset, isLittleEndian);
|
||
|
||
for (let i = 0; i < numEntries; i++) {
|
||
const entryOffset = firstIfdOffset + 2 + i * 12;
|
||
if (entryOffset + 12 > view.byteLength) break;
|
||
const tag = view.getUint16(entryOffset, isLittleEndian);
|
||
|
||
if (tag === 0x010F) { // Make
|
||
result.make = readTagValue(view, tiffOffset, entryOffset, isLittleEndian) || "";
|
||
} else if (tag === 0x0110) { // Model
|
||
result.model = readTagValue(view, tiffOffset, entryOffset, isLittleEndian) || "";
|
||
} else if (tag === 0x8769) { // Exif Sub-IFD pointer
|
||
exifSubIfdOffset = tiffOffset + view.getUint32(entryOffset + 8, isLittleEndian);
|
||
} else if (tag === 0x8825) { // GPS IFD pointer
|
||
gpsIfdOffset = tiffOffset + view.getUint32(entryOffset + 8, isLittleEndian);
|
||
} else if (tag === 0x0132 && !result.dateTime) { // DateTime
|
||
result.dateTime = readTagValue(view, tiffOffset, entryOffset, isLittleEndian) || "";
|
||
}
|
||
}
|
||
}
|
||
|
||
// Parse Exif Sub-IFD (Shooting parameters)
|
||
if (exifSubIfdOffset && exifSubIfdOffset + 2 <= view.byteLength) {
|
||
result.hasExif = true;
|
||
const numSubEntries = view.getUint16(exifSubIfdOffset, isLittleEndian);
|
||
|
||
for (let i = 0; i < numSubEntries; i++) {
|
||
const entryOffset = exifSubIfdOffset + 2 + i * 12;
|
||
if (entryOffset + 12 > view.byteLength) break;
|
||
const tag = view.getUint16(entryOffset, isLittleEndian);
|
||
|
||
if (tag === 0x829A) { // ExposureTime (Shutter Speed)
|
||
const val = readTagValue(view, tiffOffset, entryOffset, isLittleEndian);
|
||
if (val) {
|
||
if (val < 1) {
|
||
result.shutterSpeed = "1/" + Math.round(1 / val) + "s";
|
||
} else {
|
||
result.shutterSpeed = val.toFixed(1) + "s";
|
||
}
|
||
}
|
||
} else if (tag === 0x829D) { // FNumber (Aperture)
|
||
const val = readTagValue(view, tiffOffset, entryOffset, isLittleEndian);
|
||
if (val) {
|
||
result.aperture = "f/" + (val >= 10 ? val.toFixed(0) : val.toFixed(1));
|
||
}
|
||
} else if (tag === 0x8827) { // ISOSpeedRatings
|
||
const val = readTagValue(view, tiffOffset, entryOffset, isLittleEndian);
|
||
if (val) {
|
||
result.iso = "ISO " + val;
|
||
}
|
||
} else if (tag === 0x920A) { // FocalLength
|
||
const val = readTagValue(view, tiffOffset, entryOffset, isLittleEndian);
|
||
if (val) {
|
||
result.focalLength = Math.round(val) + "mm";
|
||
}
|
||
} else if (tag === 0xA405) { // FocalLengthIn35mmFilm
|
||
const val = readTagValue(view, tiffOffset, entryOffset, isLittleEndian);
|
||
if (val) {
|
||
result.focalLength35mm = val + "mm";
|
||
result.focalLength = val + "mm";
|
||
}
|
||
} else if (tag === 0x9003) { // DateTimeOriginal
|
||
const dt = readTagValue(view, tiffOffset, entryOffset, isLittleEndian);
|
||
if (dt) result.dateTime = dt;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Parse GPS Sub-IFD
|
||
if (gpsIfdOffset && gpsIfdOffset + 2 <= view.byteLength) {
|
||
try {
|
||
result.location = "Ульяновск, Волга";
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn("EXIF Parser warning:", err);
|
||
}
|
||
|
||
// Format Combined Camera / Engine
|
||
const fullCamera = (result.make + " " + result.model).trim();
|
||
if (fullCamera) {
|
||
result.camera = fullCamera;
|
||
result.captureEngine = fullCamera;
|
||
}
|
||
|
||
// Auto-detect Badge type based on file name or camera
|
||
const ext = file.name.split('.').pop().toLowerCase();
|
||
if (ext === "dng") {
|
||
result.badge = "RAW DNG // VAGABOND";
|
||
if (result.camera.includes("iPhone")) result.badge = "Apple ProRAW";
|
||
} else if (ext === "heic") {
|
||
result.badge = "HEIC PRO // 48MP";
|
||
} else if (result.focalLength.includes("77mm") || result.focalLength.includes("120mm")) {
|
||
result.badge = "RAW MACRO // BUSHIDO";
|
||
} else {
|
||
result.badge = "RAW DNG // ASPECT";
|
||
}
|
||
|
||
resolve(result);
|
||
};
|
||
|
||
reader.onerror = () => resolve({
|
||
hasExif: false,
|
||
camera: "Mobile Camera",
|
||
focalLength: "24mm",
|
||
aperture: "f/1.8",
|
||
shutterSpeed: "1/120s",
|
||
iso: "ISO 100",
|
||
captureEngine: "Mobile RAW",
|
||
location: "Ульяновск, Волга",
|
||
badge: "RAW DNG"
|
||
});
|
||
|
||
// Read first 256KB to capture EXIF APP1 Segment
|
||
reader.readAsArrayBuffer(file.slice(0, 262144));
|
||
});
|
||
}
|
||
|
||
return {
|
||
compressImage: compressImage,
|
||
extractExif: extractExif
|
||
};
|
||
})();
|