mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 08:10:28 +09:00
서버, 클라이언트, 서비스 워커 공용 코드를 lib/shared로 분리하고 브라우저 전용 모듈을 lib/client로 재구성
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6a6aa1765b
commit
aee87fd9b6
82 files changed
+326
-221
No files matched your search
@@ -0,0 +1,27 @@
|
||||
import ExifReader from "exifreader";
|
||||
|
||||
export const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
|
||||
const exif = ExifReader.load(fileBuffer);
|
||||
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
|
||||
const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description;
|
||||
if (!dateTimeOriginal) return undefined;
|
||||
|
||||
const [date, time] = dateTimeOriginal.split(" ");
|
||||
if (!date || !time) return undefined;
|
||||
|
||||
const [year, month, day] = date.split(":").map(Number);
|
||||
const [hour, minute, second] = time.split(":").map(Number);
|
||||
if (!year || !month || !day || !hour || !minute || !second) return undefined;
|
||||
|
||||
if (!offsetTimeOriginal) {
|
||||
// No timezone information.. Assume local timezone
|
||||
return new Date(year, month - 1, day, hour, minute, second);
|
||||
}
|
||||
|
||||
const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1;
|
||||
const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number);
|
||||
|
||||
const utcDate = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
|
||||
return new Date(utcDate - offsetMs);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { encodeToBase64 } from "#lib/shared/crypto/index.js";
|
||||
|
||||
const scaleSize = (width: number, height: number, targetSize: number) => {
|
||||
if (width <= targetSize || height <= targetSize) {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
const scale = targetSize / Math.min(width, height);
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale),
|
||||
};
|
||||
};
|
||||
|
||||
const capture = (
|
||||
width: number,
|
||||
height: number,
|
||||
drawer: (context: CanvasRenderingContext2D, width: number, height: number) => void,
|
||||
targetSize = 250,
|
||||
) => {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const { width: scaledWidth, height: scaledHeight } = scaleSize(width, height, targetSize);
|
||||
|
||||
canvas.width = scaledWidth;
|
||||
canvas.height = scaledHeight;
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return reject(new Error("Failed to generate thumbnail"));
|
||||
}
|
||||
|
||||
drawer(context, scaledWidth, scaledHeight);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob && blob.type === "image/webp") {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error("Failed to generate thumbnail"));
|
||||
}
|
||||
}, "image/webp");
|
||||
});
|
||||
};
|
||||
|
||||
const generateImageThumbnail = (imageUrl: string) => {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
capture(image.width, image.height, (context, width, height) => {
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
})
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
};
|
||||
image.onerror = reject;
|
||||
image.src = imageUrl;
|
||||
});
|
||||
};
|
||||
|
||||
export const captureVideoThumbnail = (video: HTMLVideoElement) => {
|
||||
return capture(video.videoWidth, video.videoHeight, (context, width, height) => {
|
||||
context.drawImage(video, 0, 0, width, height);
|
||||
});
|
||||
};
|
||||
|
||||
const generateVideoThumbnail = (videoUrl: string, time = 0) => {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const video = document.createElement("video");
|
||||
video.onloadedmetadata = () => {
|
||||
if (video.videoWidth === 0 || video.videoHeight === 0) {
|
||||
return reject();
|
||||
}
|
||||
|
||||
const callbackId = video.requestVideoFrameCallback(() => {
|
||||
captureVideoThumbnail(video).then(resolve).catch(reject);
|
||||
video.cancelVideoFrameCallback(callbackId);
|
||||
});
|
||||
video.currentTime = Math.min(time, video.duration);
|
||||
};
|
||||
video.onerror = reject;
|
||||
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.src = videoUrl;
|
||||
});
|
||||
};
|
||||
|
||||
export const generateThumbnail = async (blob: Blob) => {
|
||||
let url;
|
||||
try {
|
||||
if (blob.type.startsWith("image/")) {
|
||||
url = URL.createObjectURL(blob);
|
||||
try {
|
||||
return await generateImageThumbnail(url);
|
||||
} catch {
|
||||
URL.revokeObjectURL(url);
|
||||
url = undefined;
|
||||
|
||||
if (blob.type === "image/heic") {
|
||||
const { default: heic2any } = await import("heic2any");
|
||||
url = URL.createObjectURL((await heic2any({ blob, toType: "image/png" })) as Blob);
|
||||
return await generateImageThumbnail(url);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} else if (blob.type.startsWith("video/")) {
|
||||
url = URL.createObjectURL(blob);
|
||||
return await generateVideoThumbnail(url);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => {
|
||||
return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`;
|
||||
};
|
||||
Reference in new issue
Block a user