From 2fb584e8e144d49bd73124d7a977ed580304ad95 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 25 Jun 2025 01:45:26 +0800 Subject: [PATCH] feat: add ExifTool integration and enhance EXIF data handling - Introduced ExifTool support by adding the '@uswriting/exiftool' package to manage EXIF metadata extraction. - Updated ExifPanel component to conditionally render raw EXIF data based on ExifTool availability. - Enhanced localization files to include new EXIF metadata categories and descriptions for better user experience across multiple languages. - Added a new atom to manage the loading state of ExifTool, improving state management in the application. Signed-off-by: Innei --- apps/web/package.json | 2 + apps/web/src/atoms/app.ts | 3 + apps/web/src/components/ui/dialog/dialog.tsx | 185 +++++ apps/web/src/components/ui/dialog/index.ts | 12 + .../components/ui/photo-viewer/ExifPanel.tsx | 8 +- .../ui/photo-viewer/RawExifViewer.tsx | 669 ++++++++++++++++++ apps/web/src/lib/exiftool.ts | 43 ++ locales/app/en.json | 20 + locales/app/jp.json | 58 +- locales/app/ko.json | 22 +- locales/app/zh-CN.json | 20 + locales/app/zh-HK.json | 22 +- locales/app/zh-TW.json | 22 +- pnpm-lock.yaml | 11 + 14 files changed, 1074 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/components/ui/dialog/dialog.tsx create mode 100644 apps/web/src/components/ui/dialog/index.ts create mode 100644 apps/web/src/components/ui/photo-viewer/RawExifViewer.tsx create mode 100644 apps/web/src/lib/exiftool.ts diff --git a/apps/web/package.json b/apps/web/package.json index 1e841dc9..4d8bf9c0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@headlessui/react": "2.2.4", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-context-menu": "2.2.15", + "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-dropdown-menu": "2.1.15", "@radix-ui/react-popover": "1.1.14", "@radix-ui/react-scroll-area": "1.2.9", @@ -33,6 +34,7 @@ "@t3-oss/env-core": "0.13.8", "@tanstack/react-query": "5.80.7", "@use-gesture/react": "10.3.1", + "@uswriting/exiftool": "1.0.3", "@vercel/analytics": "1.5.0", "blurhash": "2.0.5", "clsx": "2.1.1", diff --git a/apps/web/src/atoms/app.ts b/apps/web/src/atoms/app.ts index 4d24ba92..33b8d2be 100644 --- a/apps/web/src/atoms/app.ts +++ b/apps/web/src/atoms/app.ts @@ -1,3 +1,4 @@ +import { atom } from 'jotai' import { atomWithStorage } from 'jotai/utils' export type GallerySortBy = 'date' @@ -9,3 +10,5 @@ export const gallerySettingAtom = atomWithStorage('gallery-settings', { selectedTags: [] as string[], columns: 'auto' as number | 'auto', // 自定义列数,auto 表示自动计算 }) + +export const isExiftoolLoadedAtom = atom(false) diff --git a/apps/web/src/components/ui/dialog/dialog.tsx b/apps/web/src/components/ui/dialog/dialog.tsx new file mode 100644 index 00000000..c607c978 --- /dev/null +++ b/apps/web/src/components/ui/dialog/dialog.tsx @@ -0,0 +1,185 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { AnimatePresence, m } from 'motion/react' +import * as React from 'react' + +import { clsxm } from '~/lib/cn' +import { Spring } from '~/lib/spring' + +const DialogContext = React.createContext<{ open: boolean }>({ open: false }) + +const Dialog = ({ + children, + ...props +}: React.ComponentProps) => { + const [open, setOpen] = React.useState(props.open || false) + + React.useEffect(() => { + if (props.open !== undefined) { + setOpen(props.open) + } + }, [props.open]) + + return ( + ({ open }), [open])}> + { + setOpen(openState) + props.onOpenChange?.(openState) + }} + > + {children} + + + ) +} + +const DialogTrigger = DialogPrimitive.Trigger +const DialogClose = DialogPrimitive.Close + +const DialogPortal = ({ + children, + ...props +}: React.ComponentProps) => { + const { open } = React.use(DialogContext) + + return ( + + {open && children} + + ) +} + +const DialogOverlay = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject | null> +}) => ( + + + +) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = ({ + ref, + className, + children, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject | null> +}) => ( + + + + + {children} + + + +) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = 'DialogHeader' + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = 'DialogFooter' + +const DialogTitle = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject | null> +}) => ( + +) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject | null> +}) => ( + +) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/apps/web/src/components/ui/dialog/index.ts b/apps/web/src/components/ui/dialog/index.ts new file mode 100644 index 00000000..58c505ff --- /dev/null +++ b/apps/web/src/components/ui/dialog/index.ts @@ -0,0 +1,12 @@ +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} from './dialog' diff --git a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx index bbe77057..9b08b0ce 100644 --- a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx +++ b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx @@ -2,11 +2,13 @@ import './PhotoViewer.css' import type { PickedExif } from '@afilmory/data' import { isNil } from 'es-toolkit/compat' +import { useAtomValue } from 'jotai' import { m } from 'motion/react' import type { FC } from 'react' import { Fragment } from 'react' import { useTranslation } from 'react-i18next' +import { isExiftoolLoadedAtom } from '~/atoms/app' import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea' import { useMobile } from '~/hooks/useMobile' import { @@ -23,6 +25,7 @@ import type { PhotoManifest } from '~/types/photo' import { MotionButtonBase } from '../button' import { formatExifData, Row } from './formatExifData' import { HistogramChart } from './HistogramChart' +import { RawExifViewer } from './RawExifViewer' export const ExifPanel: FC<{ currentPhoto: PhotoManifest @@ -33,7 +36,7 @@ export const ExifPanel: FC<{ const { t } = useTranslation() const isMobile = useMobile() const formattedExifData = formatExifData(exifData) - + const isExiftoolLoaded = useAtomValue(isExiftoolLoadedAtom) // 使用通用的图片格式提取函数 const imageFormat = getImageFormat( currentPhoto.originalUrl || currentPhoto.s3Key || '', @@ -64,6 +67,9 @@ export const ExifPanel: FC<{

{t('exif.header.title')}

+ {!isMobile && isExiftoolLoaded && ( + + )} {isMobile && onClose && ( + + + + + {t('exif.raw.title', { defaultValue: 'Raw EXIF Data' })} + + + {t('exif.raw.description', { + defaultValue: + 'Complete EXIF metadata extracted from the image file', + })} + + + + {isLoading && ( +
+ + + {t('exif.raw.loading', { + defaultValue: 'Loading EXIF data...', + })} + +
+ )} + + +
+ {/* Basic File Information */} + {getCategoryData(categories.basic).length > 0 && ( +
+

+ {t('exif.raw.category.basic', { + defaultValue: 'File Information', + })} +

+
+ {getCategoryData(categories.basic).map(([key, value]) => ( + + ))} +
+
+ )} + + {/* Camera Information */} + {getCategoryData(categories.camera).length > 0 && ( +
+

+ {t('exif.raw.category.camera', { + defaultValue: 'Camera Information', + })} +

+
+ {getCategoryData(categories.camera).map(([key, value]) => ( + + ))} +
+
+ )} + + {/* Exposure Settings */} + {getCategoryData(categories.exposure).length > 0 && ( +
+

+ {t('exif.raw.category.exposure', { + defaultValue: 'Exposure Settings', + })} +

+
+ {getCategoryData(categories.exposure).map(([key, value]) => ( + + ))} +
+
+ )} + + {/* Lens Information */} + {getCategoryData(categories.lens).length > 0 && ( +
+

+ {t('exif.raw.category.lens', { + defaultValue: 'Lens Information', + })} +

+
+ {getCategoryData(categories.lens).map(([key, value]) => ( + + ))} +
+
+ )} + + {/* Date & Time */} + {getCategoryData(categories.datetime).length > 0 && ( +
+

+ {t('exif.raw.category.datetime', { + defaultValue: 'Date & Time', + })} +

+
+ {getCategoryData(categories.datetime).map(([key, value]) => ( + + ))} +
+
+ )} + + {/* GPS Information */} + {getCategoryData(categories.gps).length > 0 && ( +
+

+ {t('exif.raw.category.gps', { + defaultValue: 'GPS Information', + })} +

+
+ {getCategoryData(categories.gps).map(([key, value]) => ( + + ))} +
+
+ )} + + {/* Focus System */} + {getCategoryData(categories.focus).length > 0 && ( +
+

+ {t('exif.raw.category.focus', { + defaultValue: 'Focus System', + })} +

+
+ {getCategoryData(categories.focus).map(([key, value]) => ( + + ))} +
+
+ )} + + {/* Flash & Lighting */} + {getCategoryData(categories.flash).length > 0 && ( +
+

+ {t('exif.raw.category.flash', { + defaultValue: 'Flash & Lighting', + })} +

+
+ {getCategoryData(categories.flash).map(([key, value]) => ( + + ))} +
+
+ )} + + {/* Image Properties */} + {getCategoryData(categories.imageProperties).length > 0 && ( +
+

+ {t('exif.raw.category.imageProperties', { + defaultValue: 'Image Properties', + })} +

+
+ {getCategoryData(categories.imageProperties).map( + ([key, value]) => ( + + ), + )} +
+
+ )} + + {/* White Balance */} + {getCategoryData(categories.whiteBalance).length > 0 && ( +
+

+ {t('exif.raw.category.whiteBalance', { + defaultValue: 'White Balance', + })} +

+
+ {getCategoryData(categories.whiteBalance).map( + ([key, value]) => ( + + ), + )} +
+
+ )} + + {/* Fuji Recipe */} + {getCategoryData(categories.fuji).length > 0 && ( +
+

+ {t('exif.raw.category.fuji', { + defaultValue: 'Fuji Film Simulation', + })} +

+
+ {getCategoryData(categories.fuji).map(([key, value]) => ( + + ))} +
+
+ )} + + {/* Technical Parameters */} + {getCategoryData(categories.technical).length > 0 && ( +
+

+ {t('exif.raw.category.technical', { + defaultValue: 'Technical Parameters', + })} +

+
+ {getCategoryData(categories.technical).map(([key, value]) => ( + + ))} +
+
+ )} + + {/* Video/HEIF Properties */} + {getCategoryData(categories.video).length > 0 && ( +
+

+ {t('exif.raw.category.video', { + defaultValue: 'Video/HEIF Properties', + })} +

+
+ {getCategoryData(categories.video).map(([key, value]) => ( + + ))} +
+
+ )} + + {/* Face Detection */} + {getCategoryData(categories.faceDetection).length > 0 && ( +
+

+ {t('exif.raw.category.faceDetection', { + defaultValue: 'Face Detection', + })} +

+
+ {getCategoryData(categories.faceDetection).map( + ([key, value]) => ( + + ), + )} +
+
+ )} + + {/* Other Data */} + {getCategoryData(categories.other).length > 0 && ( +
+

+ {t('exif.raw.category.other', { + defaultValue: 'Other Metadata', + })} +

+
+ {getCategoryData(categories.other).map(([key, value]) => ( + + ))} +
+
+ )} + + {/* Uncategorized Data */} + {getUncategorizedData().length > 0 && ( +
+

+ {t('exif.raw.category.uncategorized', { + defaultValue: 'Uncategorized', + })} +

+
+ {getUncategorizedData().map(([key, value]) => ( + + ))} +
+
+ )} + + {dataEntries.length === 0 && ( +
+ {t('exif.raw.no.data', { + defaultValue: 'No EXIF data available', + })} +
+ )} +
+
+
+ + ) +} diff --git a/apps/web/src/lib/exiftool.ts b/apps/web/src/lib/exiftool.ts new file mode 100644 index 00000000..ecdd693d --- /dev/null +++ b/apps/web/src/lib/exiftool.ts @@ -0,0 +1,43 @@ +import { isExiftoolLoadedAtom } from '~/atoms/app' + +import { jotaiStore } from './jotai' + +class ExifToolManagerStatic { + private isLoaded = false + + private exifTool: typeof import('@uswriting/exiftool') | null = null + + async load() { + if (this.isLoaded) return + const exiftool = await import('@uswriting/exiftool') + console.info('ExifTool loaded...') + this.exifTool = exiftool + this.isLoaded = true + + jotaiStore.set(isExiftoolLoadedAtom, true) + } + + constructor() { + this.load() + } + + async parse(buffer: Blob, filename?: string) { + if (!this.exifTool) { + await this.load() + } + + if (!this.exifTool) { + throw new Error('ExifTool not loaded') + } + const metadata = await this.exifTool.parseMetadata( + new File([buffer], `/afilmory/${filename}`), + ) + + if (metadata.error) { + throw new Error(metadata.error) + } + + return metadata.data + } +} +export const ExifToolManager = new ExifToolManagerStatic() diff --git a/locales/app/en.json b/locales/app/en.json index ac2b8ff0..5f6c8d60 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -143,6 +143,26 @@ "exif.noise.reduction": "Noise Reduction", "exif.not.available": "N/A", "exif.pixels": "Pixels", + "exif.raw.category.basic": "File Information", + "exif.raw.category.camera": "Camera Information", + "exif.raw.category.datetime": "Date & Time", + "exif.raw.category.exposure": "Exposure Settings", + "exif.raw.category.faceDetection": "Face Detection", + "exif.raw.category.flash": "Flash & Lighting", + "exif.raw.category.focus": "Focus System", + "exif.raw.category.fuji": "Fuji Film Simulation", + "exif.raw.category.gps": "GPS Information", + "exif.raw.category.imageProperties": "Image Properties", + "exif.raw.category.lens": "Lens Information", + "exif.raw.category.other": "Other Metadata", + "exif.raw.category.technical": "Technical Parameters", + "exif.raw.category.uncategorized": "Uncategorized", + "exif.raw.category.video": "Video/HEIF Properties", + "exif.raw.category.whiteBalance": "White Balance", + "exif.raw.description": "Complete EXIF metadata extracted from the image file", + "exif.raw.no.data": "No EXIF data available", + "exif.raw.parse.error": "Failed to parse EXIF data", + "exif.raw.title": "Raw EXIF Data", "exif.red.adjustment": "Red Adjustment", "exif.resolution.unit.cm": "Centimeters", "exif.resolution.unit.inches": "Inches", diff --git a/locales/app/jp.json b/locales/app/jp.json index fb3055e6..44a0d3fc 100644 --- a/locales/app/jp.json +++ b/locales/app/jp.json @@ -3,8 +3,8 @@ "action.columns.setting": "列設定", "action.sort.mode": "ソートモード", "action.tag.filter": "タグフィルター", - "action.view.github": "GitHubリポジトリを表示", - "error.feedback": "まだ問題が解決しませんか?GitHubでフィードバックをお願いします。", + "action.view.github": "GitHub リポジトリを表示", + "error.feedback": "まだ問題が解決しませんか?GitHub でフィードバックをお願いします。", "error.reload": "再読み込み", "error.submit.issue": "問題を報告", "error.temporary.description": "アプリケーションで一時的な問題が発生しました。下のボタンをクリックしてアプリケーションを再読み込みするか、他の解決策をお試しください。", @@ -49,7 +49,7 @@ "exif.exposureprogram.normal": "標準", "exif.exposureprogram.not-defined": "未定義", "exif.exposureprogram.portrait": "ポートレートモード", - "exif.exposureprogram.program-ae": "プログラムAE", + "exif.exposureprogram.program-ae": "プログラム AE", "exif.exposureprogram.shutter-priority": "シャッター優先", "exif.exposureprogram.title": "露出プログラム", "exif.file.size": "ファイルサイズ", @@ -74,7 +74,7 @@ "exif.flash.title": "フラッシュ", "exif.flash.unavailable": "フラッシュ機能なし", "exif.focal.length.actual": "焦点距離", - "exif.focal.length.equivalent": "35mm換算", + "exif.focal.length.equivalent": "35mm 換算", "exif.focal.plane.resolution": "焦点面解像度", "exif.format": "フォーマット", "exif.fuji.film.simulation": "フィルムシミュレーションレシピ", @@ -119,7 +119,7 @@ "exif.light.source.fine.weather": "晴天", "exif.light.source.flash": "フラッシュ", "exif.light.source.fluorescent": "蛍光灯", - "exif.light.source.iso.tungsten": "ISOスタジオタングステン", + "exif.light.source.iso.tungsten": "ISO スタジオタングステン", "exif.light.source.other": "その他の光源", "exif.light.source.shade": "日陰", "exif.light.source.standard.a": "標準光源 A", @@ -143,6 +143,26 @@ "exif.noise.reduction": "ノイズリダクション", "exif.not.available": "N/A", "exif.pixels": "ピクセル", + "exif.raw.category.basic": "ファイル情報", + "exif.raw.category.camera": "カメラ情報", + "exif.raw.category.datetime": "日時", + "exif.raw.category.exposure": "露出設定", + "exif.raw.category.faceDetection": "顔検出", + "exif.raw.category.flash": "フラッシュ・光源", + "exif.raw.category.focus": "フォーカスシステム", + "exif.raw.category.fuji": "富士フィルムシミュレーション", + "exif.raw.category.gps": "GPS 情報", + "exif.raw.category.imageProperties": "画像プロパティ", + "exif.raw.category.lens": "レンズ情報", + "exif.raw.category.other": "その他のメタデータ", + "exif.raw.category.technical": "技術パラメータ", + "exif.raw.category.uncategorized": "未分類", + "exif.raw.category.video": "動画/HEIF プロパティ", + "exif.raw.category.whiteBalance": "ホワイトバランス", + "exif.raw.description": "画像ファイルから抽出された完全な EXIF メタデータ", + "exif.raw.no.data": "EXIF データがありません", + "exif.raw.parse.error": "EXIF データの解析に失敗しました", + "exif.raw.title": "生の EXIF データ", "exif.red.adjustment": "レッド調整", "exif.resolution.unit.cm": "センチメートル", "exif.resolution.unit.inches": "インチ", @@ -151,11 +171,11 @@ "exif.scene.capture.type": "シーン撮影タイプ", "exif.sensing.method.color.sequential.linear": "カラーシーケンシャルリニアセンサー", "exif.sensing.method.color.sequential.main": "カラーシーケンシャルエリアセンサー", - "exif.sensing.method.one-chip-color-area": "1チップカラーエリアセンサー", - "exif.sensing.method.one.chip": "1チップカラーエリアセンサー", - "exif.sensing.method.three.chip": "3チップカラーエリアセンサー", + "exif.sensing.method.one-chip-color-area": "1 チップカラーエリアセンサー", + "exif.sensing.method.one.chip": "1 チップカラーエリアセンサー", + "exif.sensing.method.three.chip": "3 チップカラーエリアセンサー", "exif.sensing.method.trilinear": "トライリニアセンサー", - "exif.sensing.method.two.chip": "2チップカラーエリアセンサー", + "exif.sensing.method.two.chip": "2 チップカラーエリアセンサー", "exif.sensing.method.type": "撮像方式", "exif.sensing.method.undefined": "未定義", "exif.shadow.ratio": "シャドウ比率", @@ -183,7 +203,7 @@ "exif.white.balance.manual": "手動", "exif.white.balance.red": "レッド", "exif.white.balance.shift.ab": "ホワイトバランス補正 (アンバー-ブルー)", - "exif.white.balance.shift.gm": "ホワイトバランス補正 (グリーン-マゼンタ)", + "exif.white.balance.shift.gm": "ホワイトバランス補正 (グリーン - マゼンタ)", "exif.white.balance.title": "ホワイトバランス", "gallery.built.at": "ビルド日時 ", "gallery.photos_one": "写真{{count}}枚", @@ -200,10 +220,10 @@ "histogram.value": "値", "loading.converting": "変換中...", "loading.default": "読み込み中", - "loading.heic.converting": "HEIC/HEIF画像フォーマットを変換中...", + "loading.heic.converting": "HEIC/HEIF 画像フォーマットを変換中...", "loading.heic.main": "HEIC", "loading.webgl.building": "高品質テクスチャを構築中...", - "loading.webgl.main": "WebGLテクスチャの読み込み", + "loading.webgl.main": "WebGL テクスチャの読み込み", "photo.conversion.transmux": "トランスマックス", "photo.conversion.webcodecs": "WebCodecs", "photo.copy.error": "画像のコピーに失敗しました。後でもう一度お試しください。", @@ -229,12 +249,12 @@ "photo.share.text": "この素敵な写真を見てください:{{title}}", "photo.share.title": "写真を共有", "photo.share.weibo": "Weibo", - "photo.webgl.unavailable": "WebGLが利用できないため、画像をレンダリングできません", + "photo.webgl.unavailable": "WebGL が利用できないため、画像をレンダリングできません", "photo.zoom.hint": "ダブルタップまたはピンチしてズーム", "slider.auto": "自動", "video.codec.keyword": "エンコーダー", "video.conversion.cached.result": "キャッシュされた結果を使用", - "video.conversion.codec.fallback": "この解像度でサポートされているMP4コーデックが見つかりません。WebMにフォールバックします。", + "video.conversion.codec.fallback": "この解像度でサポートされている MP4 コーデックが見つかりません。WebM にフォールバックします。", "video.conversion.complete": "変換完了", "video.conversion.converting": "変換中... {{current}}/{{total}}フレーム", "video.conversion.duration.error": "ビデオの長さを特定できないか、長さが有限ではありません。", @@ -245,8 +265,8 @@ "video.conversion.starting": "変換を開始しています...", "video.conversion.transmux.high.quality": "高品質トランスマックス変換器を使用中...", "video.conversion.transmux.not.supported": "このブラウザはトランスマックスをサポートしていません", - "video.conversion.webcodecs.high.quality": "高品質のWebCodecsコンバーターを使用しています...", - "video.conversion.webcodecs.not.supported": "このブラウザはWebCodecsをサポートしていません", - "video.format.mov.not.supported": "ブラウザがMOV形式をサポートしていないため、変換が必要です", - "video.format.mov.supported": "ブラウザがMOV形式をネイティブでサポートしているため、変換をスキップします" -} + "video.conversion.webcodecs.high.quality": "高品質の WebCodecs コンバーターを使用しています...", + "video.conversion.webcodecs.not.supported": "このブラウザは WebCodecs をサポートしていません", + "video.format.mov.not.supported": "ブラウザが MOV 形式をサポートしていないため、変換が必要です", + "video.format.mov.supported": "ブラウザが MOV 形式をネイティブでサポートしているため、変換をスキップします" +} \ No newline at end of file diff --git a/locales/app/ko.json b/locales/app/ko.json index e61e28a4..948f3bf7 100644 --- a/locales/app/ko.json +++ b/locales/app/ko.json @@ -143,6 +143,26 @@ "exif.noise.reduction": "노이즈 감소", "exif.not.available": "N/A", "exif.pixels": "픽셀", + "exif.raw.category.basic": "파일 정보", + "exif.raw.category.camera": "카메라 정보", + "exif.raw.category.datetime": "날짜 및 시간", + "exif.raw.category.exposure": "노출 설정", + "exif.raw.category.faceDetection": "얼굴 인식", + "exif.raw.category.flash": "플래시 및 조명", + "exif.raw.category.focus": "포커스 시스템", + "exif.raw.category.fuji": "후지 필름 시뮬레이션", + "exif.raw.category.gps": "GPS 정보", + "exif.raw.category.imageProperties": "이미지 속성", + "exif.raw.category.lens": "렌즈 정보", + "exif.raw.category.other": "기타 메타데이터", + "exif.raw.category.technical": "기술 매개변수", + "exif.raw.category.uncategorized": "분류되지 않음", + "exif.raw.category.video": "비디오/HEIF 속성", + "exif.raw.category.whiteBalance": "화이트 밸런스", + "exif.raw.description": "이미지 파일에서 추출된 완전한 EXIF 메타데이터", + "exif.raw.no.data": "EXIF 데이터가 없습니다", + "exif.raw.parse.error": "EXIF 데이터 분석에 실패했습니다", + "exif.raw.title": "원시 EXIF 데이터", "exif.red.adjustment": "레드 조정", "exif.resolution.unit.cm": "센티미터", "exif.resolution.unit.inches": "인치", @@ -249,4 +269,4 @@ "video.conversion.webcodecs.not.supported": "이 브라우저는 WebCodecs 를 지원하지 않습니다", "video.format.mov.not.supported": "브라우저가 MOV 형식을 지원하지 않아 변환이 필요합니다.", "video.format.mov.supported": "브라우저가 MOV 형식을 기본적으로 지원하므로 변환을 건너뜁니다." -} +} \ No newline at end of file diff --git a/locales/app/zh-CN.json b/locales/app/zh-CN.json index 4641c3f8..2eb3affe 100644 --- a/locales/app/zh-CN.json +++ b/locales/app/zh-CN.json @@ -143,6 +143,26 @@ "exif.noise.reduction": "降噪", "exif.not.available": "不可用", "exif.pixels": "像素", + "exif.raw.category.basic": "文件信息", + "exif.raw.category.camera": "相机信息", + "exif.raw.category.datetime": "日期时间", + "exif.raw.category.exposure": "曝光设置", + "exif.raw.category.faceDetection": "人脸检测", + "exif.raw.category.flash": "闪光灯与光源", + "exif.raw.category.focus": "对焦系统", + "exif.raw.category.fuji": "富士胶片模拟", + "exif.raw.category.gps": "GPS 信息", + "exif.raw.category.imageProperties": "图像属性", + "exif.raw.category.lens": "镜头信息", + "exif.raw.category.other": "其他元数据", + "exif.raw.category.technical": "技术参数", + "exif.raw.category.uncategorized": "未分类", + "exif.raw.category.video": "视频/HEIF 属性", + "exif.raw.category.whiteBalance": "白平衡", + "exif.raw.description": "从图像文件中提取的完整 EXIF 元数据", + "exif.raw.no.data": "无 EXIF 数据", + "exif.raw.parse.error": "解析 EXIF 数据失败", + "exif.raw.title": "原始 EXIF 数据", "exif.red.adjustment": "红色调整", "exif.resolution.unit.cm": "厘米", "exif.resolution.unit.inches": "英寸", diff --git a/locales/app/zh-HK.json b/locales/app/zh-HK.json index 00f7083b..e8005780 100644 --- a/locales/app/zh-HK.json +++ b/locales/app/zh-HK.json @@ -143,6 +143,26 @@ "exif.noise.reduction": "降噪", "exif.not.available": "不可用", "exif.pixels": "像素", + "exif.raw.category.basic": "檔案資訊", + "exif.raw.category.camera": "相機資訊", + "exif.raw.category.datetime": "日期時間", + "exif.raw.category.exposure": "曝光設定", + "exif.raw.category.faceDetection": "人臉檢測", + "exif.raw.category.flash": "閃光燈與光源", + "exif.raw.category.focus": "對焦系統", + "exif.raw.category.fuji": "富士底片模擬", + "exif.raw.category.gps": "GPS 資訊", + "exif.raw.category.imageProperties": "圖像屬性", + "exif.raw.category.lens": "鏡頭資訊", + "exif.raw.category.other": "其他元數據", + "exif.raw.category.technical": "技術參數", + "exif.raw.category.uncategorized": "未分類", + "exif.raw.category.video": "影片/HEIF 屬性", + "exif.raw.category.whiteBalance": "白平衡", + "exif.raw.description": "從圖像檔案中提取的完整 EXIF 元數據", + "exif.raw.no.data": "無 EXIF 數據", + "exif.raw.parse.error": "解析 EXIF 數據失敗", + "exif.raw.title": "原始 EXIF 數據", "exif.red.adjustment": "紅色調整", "exif.resolution.unit.cm": "厘米", "exif.resolution.unit.inches": "英寸", @@ -249,4 +269,4 @@ "video.conversion.webcodecs.not.supported": "此瀏覽器不支援 WebCodecs", "video.format.mov.not.supported": "瀏覽器不支援 MOV 格式,需要轉換", "video.format.mov.supported": "瀏覽器原生支援 MOV 格式,跳過轉換" -} +} \ No newline at end of file diff --git a/locales/app/zh-TW.json b/locales/app/zh-TW.json index 8352bd62..2b5e1008 100644 --- a/locales/app/zh-TW.json +++ b/locales/app/zh-TW.json @@ -143,6 +143,26 @@ "exif.noise.reduction": "降噪", "exif.not.available": "不可用", "exif.pixels": "像素", + "exif.raw.category.basic": "檔案資訊", + "exif.raw.category.camera": "相機資訊", + "exif.raw.category.datetime": "日期時間", + "exif.raw.category.exposure": "曝光設定", + "exif.raw.category.faceDetection": "人臉檢測", + "exif.raw.category.flash": "閃光燈與光源", + "exif.raw.category.focus": "對焦系統", + "exif.raw.category.fuji": "富士底片模擬", + "exif.raw.category.gps": "GPS 資訊", + "exif.raw.category.imageProperties": "圖像屬性", + "exif.raw.category.lens": "鏡頭資訊", + "exif.raw.category.other": "其他元數據", + "exif.raw.category.technical": "技術參數", + "exif.raw.category.uncategorized": "未分類", + "exif.raw.category.video": "影片/HEIF 屬性", + "exif.raw.category.whiteBalance": "白平衡", + "exif.raw.description": "從圖像檔案中提取的完整 EXIF 元數據", + "exif.raw.no.data": "無 EXIF 數據", + "exif.raw.parse.error": "解析 EXIF 數據失敗", + "exif.raw.title": "原始 EXIF 數據", "exif.red.adjustment": "紅色調整", "exif.resolution.unit.cm": "公分", "exif.resolution.unit.inches": "英寸", @@ -249,4 +269,4 @@ "video.conversion.webcodecs.not.supported": "此瀏覽器不支援 WebCodecs", "video.format.mov.not.supported": "瀏覽器不支援 MOV 格式,需要轉換", "video.format.mov.supported": "瀏覽器原生支援 MOV 格式,跳過轉換" -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82497063..071a1210 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,6 +133,9 @@ importers: '@radix-ui/react-context-menu': specifier: 2.2.15 version: 2.2.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dialog': + specifier: 1.1.14 + version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dropdown-menu': specifier: 2.1.15 version: 2.1.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -163,6 +166,9 @@ importers: '@use-gesture/react': specifier: 10.3.1 version: 10.3.1(react@19.1.0) + '@uswriting/exiftool': + specifier: 1.0.3 + version: 1.0.3 '@vercel/analytics': specifier: 1.5.0 version: 1.5.0(next@15.3.3(@babel/core@7.27.1)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) @@ -2909,6 +2915,9 @@ packages: peerDependencies: react: '>= 16.8.0' + '@uswriting/exiftool@1.0.3': + resolution: {integrity: sha512-dw6LOo7GnG65I9fCCVbsensRaQrATvBhRhuFQsMl21JPB9CCJWrArD4/BaRQkftrjOXLVJ9qqp6/XSgcRKfnkQ==} + '@vercel/analytics@1.5.0': resolution: {integrity: sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g==} peerDependencies: @@ -9611,6 +9620,8 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.1.0 + '@uswriting/exiftool@1.0.3': {} + '@vercel/analytics@1.5.0(next@15.3.3(@babel/core@7.27.1)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': optionalDependencies: next: 15.3.3(@babel/core@7.27.1)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)