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 <tukon479@gmail.com>
This commit is contained in:
Innei
2025-06-25 01:45:26 +08:00
parent b18bb76451
commit 2fb584e8e1
14 changed files with 1074 additions and 23 deletions

View File

@@ -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",

View File

@@ -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)

View File

@@ -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<typeof DialogPrimitive.Root>) => {
const [open, setOpen] = React.useState(props.open || false)
React.useEffect(() => {
if (props.open !== undefined) {
setOpen(props.open)
}
}, [props.open])
return (
<DialogContext value={React.useMemo(() => ({ open }), [open])}>
<DialogPrimitive.Root
{...props}
open={open}
onOpenChange={(openState) => {
setOpen(openState)
props.onOpenChange?.(openState)
}}
>
{children}
</DialogPrimitive.Root>
</DialogContext>
)
}
const DialogTrigger = DialogPrimitive.Trigger
const DialogClose = DialogPrimitive.Close
const DialogPortal = ({
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) => {
const { open } = React.use(DialogContext)
return (
<DialogPrimitive.Portal forceMount {...props}>
<AnimatePresence>{open && children}</AnimatePresence>
</DialogPrimitive.Portal>
)
}
const DialogOverlay = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
ref?: React.RefObject<React.ElementRef<typeof DialogPrimitive.Overlay> | null>
}) => (
<DialogPrimitive.Overlay
ref={ref}
className={clsxm('fixed inset-0 z-[100000000]', className)}
asChild
{...props}
>
<m.div
className="bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={Spring.presets.smooth}
/>
</DialogPrimitive.Overlay>
)
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
ref?: React.RefObject<React.ElementRef<typeof DialogPrimitive.Content> | null>
}) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={clsxm(
'fixed left-[50%] top-[50%] z-[100000000] w-full max-w-lg',
className,
)}
asChild
{...props}
>
<m.div
className="border-border bg-material-medium gap-4 rounded-lg border p-6 shadow-lg backdrop-blur-[70px]"
initial={{ opacity: 0, scale: 0.95, x: '-50%', y: '-50%' }}
animate={{ opacity: 1, scale: 1, x: '-50%', y: '-50%' }}
exit={{ opacity: 0, scale: 0.95, x: '-50%', y: '-50%' }}
transition={Spring.presets.smooth}
>
{children}
</m.div>
</DialogPrimitive.Content>
</DialogPortal>
)
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={clsxm(
'flex flex-col space-y-1.5 text-center sm:text-left',
className,
)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={clsxm(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> & {
ref?: React.RefObject<React.ElementRef<typeof DialogPrimitive.Title> | null>
}) => (
<DialogPrimitive.Title
ref={ref}
className={clsxm(
'text-lg font-semibold leading-none tracking-tight text-white',
className,
)}
{...props}
/>
)
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> & {
ref?: React.RefObject<React.ElementRef<
typeof DialogPrimitive.Description
> | null>
}) => (
<DialogPrimitive.Description
ref={ref}
className={clsxm('text-sm text-white/70', className)}
{...props}
/>
)
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,12 @@
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from './dialog'

View File

@@ -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<{
<h3 className={`${isMobile ? 'text-base' : 'text-lg'} font-semibold`}>
{t('exif.header.title')}
</h3>
{!isMobile && isExiftoolLoaded && (
<RawExifViewer currentPhoto={currentPhoto} />
)}
{isMobile && onClose && (
<button
type="button"

View File

@@ -0,0 +1,669 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '~/components/ui/dialog'
import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea'
import { ExifToolManager } from '~/lib/exiftool'
import type { PhotoManifest } from '~/types/photo'
interface RawExifViewerProps {
currentPhoto: PhotoManifest
}
type ParsedExifData = Record<string, string | number | boolean | null>
const parseRawExifData = (rawData: string): ParsedExifData => {
const lines = rawData.split('\n').filter((line) => line.trim())
const data: ParsedExifData = {}
for (const line of lines) {
const colonIndex = line.indexOf(':')
if (colonIndex === -1) continue
const key = line.slice(0, Math.max(0, colonIndex)).trim()
const value = line.slice(Math.max(0, colonIndex + 1)).trim()
if (key && value) {
data[key] = value
}
}
return data
}
const ExifDataRow = ({ label, value }: { label: string; value: string }) => (
<div className="flex items-center justify-between border-b border-white/15 py-2 last:border-b-0">
<span className="max-w-[45%] min-w-0 flex-shrink-0 pr-4 text-sm font-medium break-words text-white/70">
{label}
</span>
<span className="max-w-[55%] min-w-0 text-right font-mono text-sm break-words text-white/95">
{value}
</span>
</div>
)
// Group data by categories for better organization
const categories = {
basic: [
'ExifTool Version Number',
'File Name',
'Directory',
'File Size',
'File Type',
'File Type Extension',
'MIME Type',
'Major Brand',
'Minor Version',
'Compatible Brands',
],
camera: [
'Make',
'Camera Model Name',
'Model',
'Software',
'Serial Number',
'Internal Serial Number',
'Fuji Model',
'Camera Elevation Angle',
'Roll Angle',
],
exposure: [
'Exposure Time',
'F Number',
'ISO',
'Exposure Program',
'Exposure Compensation',
'Exposure Mode',
'Metering Mode',
'Shutter Speed Value',
'Aperture Value',
'Brightness Value',
'Max Aperture Value',
'Exposure Warning',
'Auto Bracketing',
],
lens: [
'Lens Info',
'Lens Make',
'Lens Model',
'Lens Serial Number',
'Focal Length',
'Focal Length In 35mm Format',
'Min Focal Length',
'Max Focal Length',
'Max Aperture At Min Focal',
'Max Aperture At Max Focal',
'Lens Modulation Optimizer',
'Lens ID',
],
focus: [
'Focus Mode',
'AF Mode',
'Focus Pixel',
'AF-S Priority',
'AF-C Priority',
'Focus Mode 2',
'Pre AF',
'AF Area Mode',
'AF Area Point Size',
'AF Area Zone Size',
'AF-C Setting',
'AF-C Tracking Sensitivity',
'AF-C Speed Tracking Sensitivity',
'AF-C Zone Area Switching',
'Focus Warning',
'Subject Distance Range',
],
flash: [
'Flash',
'Light Source',
'Fuji Flash Mode',
'Flash Exposure Comp',
'Flash Metering Mode',
'Slow Sync',
'Flicker Reduction',
],
datetime: [
'Date/Time Original',
'Create Date',
'Modify Date',
'File Modification Date/Time',
'File Access Date/Time',
'File Inode Change Date/Time',
'Offset Time',
'Offset Time Original',
'Offset Time Digitized',
'GPS Date/Time',
'GPS Time Stamp',
'GPS Date Stamp',
],
gps: [
'GPS Version ID',
'GPS Latitude',
'GPS Latitude Ref',
'GPS Longitude',
'GPS Longitude Ref',
'GPS Altitude',
'GPS Altitude Ref',
'GPS Position',
'GPS Speed',
'GPS Speed Ref',
'GPS Map Datum',
],
imageProperties: [
'Image Width',
'Image Height',
'Image Size',
'Meta Image Size',
'Exif Image Width',
'Exif Image Height',
'Image Spatial Extent',
'Orientation',
'X Resolution',
'Y Resolution',
'Resolution Unit',
'Bits Per Sample',
'Megapixels',
'Aspect Ratio',
'Color Space',
'Color Profiles',
'Color Primaries',
'Matrix Coefficients',
],
whiteBalance: [
'White Balance',
'White Balance Fine Tune',
'White Balance Bias',
'WB Shift AB',
'WB Shift GM',
'Color Temperature',
'Auto White Balance',
'Standard White Balance GRB',
],
fuji: [
'Film Mode',
'Dynamic Range',
'Dynamic Range Setting',
'Auto Dynamic Range',
'Highlight Tone',
'Shadow Tone',
'Saturation',
'Sharpness',
'Noise Reduction',
'Clarity',
'Grain Effect Roughness',
'Grain Effect Size',
'Color Chrome Effect',
'Color Chrome FX Blue',
'Picture Mode',
'Quality',
'Contrast',
'Image Generation',
'Image Count',
'Exposure Count',
],
technical: [
'Sensing Method',
'File Source',
'Scene Type',
'Scene Capture Type',
'Custom Rendered',
'Focal Plane X Resolution',
'Focal Plane Y Resolution',
'Focal Plane Resolution Unit',
'Image Stabilization',
'Blur Warning',
'Shutter Type',
'Drive Mode',
'Drive Speed',
'Sequence Number',
'Scale Factor To 35 mm Equivalent',
'Circle Of Confusion',
'Field Of View',
'Hyperfocal Distance',
'Light Value',
],
video: [
'HEVC Configuration Version',
'General Profile Space',
'General Tier Flag',
'General Profile IDC',
'Gen Profile Compatibility Flags',
'Constraint Indicator Flags',
'General Level IDC',
'Min Spatial Segmentation IDC',
'Parallelism Type',
'Chroma Format',
'Bit Depth Luma',
'Bit Depth Chroma',
'Average Frame Rate',
'Constant Frame Rate',
'Num Temporal Layers',
'Temporal ID Nested',
'Transfer Characteristics',
'Video Full Range Flag',
'Image Pixel Depth',
'Rotation',
'Media Data Size',
'Media Data Offset',
],
faceDetection: ['Faces Detected', 'Num Face Elements', 'Face Detection'],
other: [
'File Permissions',
'Handler Type',
'Primary Item Reference',
'Other Image',
'Preview Image',
'Thumbnail Image',
'Exif Byte Order',
'Y Cb Cr Positioning',
'Copyright',
'Components Configuration',
'Compressed Bits Per Pixel',
'Version',
'Flashpix Version',
'Interoperability Index',
'Interoperability Version',
'Composite Image',
'PrintIM Version',
'Artist',
'Rating',
'User Comment',
],
}
export const RawExifViewer: React.FC<RawExifViewerProps> = ({
currentPhoto,
}) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [rawExifData, setRawExifData] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
setIsOpen(false)
setRawExifData(null)
setIsLoading(false)
}, [currentPhoto.id])
const handleOpenModal = async () => {
if (rawExifData) {
setIsOpen(true)
return
}
setIsLoading(true)
try {
const response = await fetch(currentPhoto.originalUrl)
const blob = await response.blob()
const data = await ExifToolManager.parse(blob, currentPhoto.s3Key)
setRawExifData(data || null)
setIsOpen(true)
} catch (error) {
console.error('Failed to parse EXIF data:', error)
toast.error(
t('exif.raw.parse.error', {
defaultValue: 'Failed to parse EXIF data',
}),
)
} finally {
setIsLoading(false)
}
}
const parsedData = rawExifData ? parseRawExifData(rawExifData) : {}
const dataEntries = Object.entries(parsedData)
const getCategoryData = (categoryKeys: string[]) => {
return dataEntries.filter(([key]) =>
categoryKeys.some((catKey) => key.includes(catKey)),
)
}
const getUncategorizedData = () => {
const allCategoryKeys = Object.values(categories).flat()
return dataEntries.filter(
([key]) => !allCategoryKeys.some((catKey) => key.includes(catKey)),
)
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<button
type="button"
onClick={handleOpenModal}
disabled={isLoading}
className="cursor-pointer text-white/70 duration-200 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? (
<i className="i-mingcute-loading-3-line animate-spin" />
) : (
<i className="i-mingcute-braces-line" />
)}
</button>
</DialogTrigger>
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-2 text-white">
<DialogHeader>
<DialogTitle>
{t('exif.raw.title', { defaultValue: 'Raw EXIF Data' })}
</DialogTitle>
<DialogDescription>
{t('exif.raw.description', {
defaultValue:
'Complete EXIF metadata extracted from the image file',
})}
</DialogDescription>
</DialogHeader>
{isLoading && (
<div className="flex h-full grow flex-col items-center justify-center gap-4 text-white/70">
<i className="i-mingcute-loading-3-line animate-spin text-3xl" />
<span className="text-sm">
{t('exif.raw.loading', {
defaultValue: 'Loading EXIF data...',
})}
</span>
</div>
)}
<ScrollArea
rootClassName="h-0 grow flex-1 -mb-6 -mx-6"
viewportClassName="px-7 pb-6 pt-4"
>
<div className="space-y-6">
{/* Basic File Information */}
{getCategoryData(categories.basic).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.basic', {
defaultValue: 'File Information',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.basic).map(([key, value]) => (
<ExifDataRow key={key} label={key} value={String(value)} />
))}
</div>
</div>
)}
{/* Camera Information */}
{getCategoryData(categories.camera).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.camera', {
defaultValue: 'Camera Information',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.camera).map(([key, value]) => (
<ExifDataRow key={key} label={key} value={String(value)} />
))}
</div>
</div>
)}
{/* Exposure Settings */}
{getCategoryData(categories.exposure).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.exposure', {
defaultValue: 'Exposure Settings',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.exposure).map(([key, value]) => (
<ExifDataRow key={key} label={key} value={String(value)} />
))}
</div>
</div>
)}
{/* Lens Information */}
{getCategoryData(categories.lens).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.lens', {
defaultValue: 'Lens Information',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.lens).map(([key, value]) => (
<ExifDataRow key={key} label={key} value={String(value)} />
))}
</div>
</div>
)}
{/* Date & Time */}
{getCategoryData(categories.datetime).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.datetime', {
defaultValue: 'Date & Time',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.datetime).map(([key, value]) => (
<ExifDataRow key={key} label={key} value={String(value)} />
))}
</div>
</div>
)}
{/* GPS Information */}
{getCategoryData(categories.gps).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.gps', {
defaultValue: 'GPS Information',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.gps).map(([key, value]) => (
<ExifDataRow key={key} label={key} value={String(value)} />
))}
</div>
</div>
)}
{/* Focus System */}
{getCategoryData(categories.focus).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.focus', {
defaultValue: 'Focus System',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.focus).map(([key, value]) => (
<ExifDataRow key={key} label={key} value={String(value)} />
))}
</div>
</div>
)}
{/* Flash & Lighting */}
{getCategoryData(categories.flash).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.flash', {
defaultValue: 'Flash & Lighting',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.flash).map(([key, value]) => (
<ExifDataRow key={key} label={key} value={String(value)} />
))}
</div>
</div>
)}
{/* Image Properties */}
{getCategoryData(categories.imageProperties).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.imageProperties', {
defaultValue: 'Image Properties',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.imageProperties).map(
([key, value]) => (
<ExifDataRow
key={key}
label={key}
value={String(value)}
/>
),
)}
</div>
</div>
)}
{/* White Balance */}
{getCategoryData(categories.whiteBalance).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.whiteBalance', {
defaultValue: 'White Balance',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.whiteBalance).map(
([key, value]) => (
<ExifDataRow
key={key}
label={key}
value={String(value)}
/>
),
)}
</div>
</div>
)}
{/* Fuji Recipe */}
{getCategoryData(categories.fuji).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.fuji', {
defaultValue: 'Fuji Film Simulation',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.fuji).map(([key, value]) => (
<ExifDataRow key={key} label={key} value={String(value)} />
))}
</div>
</div>
)}
{/* Technical Parameters */}
{getCategoryData(categories.technical).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.technical', {
defaultValue: 'Technical Parameters',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.technical).map(([key, value]) => (
<ExifDataRow key={key} label={key} value={String(value)} />
))}
</div>
</div>
)}
{/* Video/HEIF Properties */}
{getCategoryData(categories.video).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.video', {
defaultValue: 'Video/HEIF Properties',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.video).map(([key, value]) => (
<ExifDataRow key={key} label={key} value={String(value)} />
))}
</div>
</div>
)}
{/* Face Detection */}
{getCategoryData(categories.faceDetection).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.faceDetection', {
defaultValue: 'Face Detection',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.faceDetection).map(
([key, value]) => (
<ExifDataRow
key={key}
label={key}
value={String(value)}
/>
),
)}
</div>
</div>
)}
{/* Other Data */}
{getCategoryData(categories.other).length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.other', {
defaultValue: 'Other Metadata',
})}
</h4>
<div className="space-y-2">
{getCategoryData(categories.other).map(([key, value]) => (
<ExifDataRow key={key} label={key} value={String(value)} />
))}
</div>
</div>
)}
{/* Uncategorized Data */}
{getUncategorizedData().length > 0 && (
<div>
<h4 className="mb-3 border-b border-white/25 pb-2 text-sm font-semibold text-white/90">
{t('exif.raw.category.uncategorized', {
defaultValue: 'Uncategorized',
})}
</h4>
<div className="space-y-2">
{getUncategorizedData().map(([key, value]) => (
<ExifDataRow key={key} label={key} value={String(value)} />
))}
</div>
</div>
)}
{dataEntries.length === 0 && (
<div className="py-8 text-center text-white/50">
{t('exif.raw.no.data', {
defaultValue: 'No EXIF data available',
})}
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@@ -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()

View File

@@ -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",

View File

@@ -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 形式をネイティブでサポートしているため、変換をスキップします"
}

View File

@@ -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 형식을 기본적으로 지원하므로 변환을 건너뜁니다."
}
}

View File

@@ -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": "英寸",

View File

@@ -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 格式,跳過轉換"
}
}

View File

@@ -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 格式,跳過轉換"
}
}

11
pnpm-lock.yaml generated
View File

@@ -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)