mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
185
apps/web/src/components/ui/dialog/dialog.tsx
Normal file
185
apps/web/src/components/ui/dialog/dialog.tsx
Normal 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,
|
||||
}
|
||||
12
apps/web/src/components/ui/dialog/index.ts
Normal file
12
apps/web/src/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './dialog'
|
||||
@@ -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"
|
||||
|
||||
669
apps/web/src/components/ui/photo-viewer/RawExifViewer.tsx
Normal file
669
apps/web/src/components/ui/photo-viewer/RawExifViewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
apps/web/src/lib/exiftool.ts
Normal file
43
apps/web/src/lib/exiftool.ts
Normal 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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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 形式をネイティブでサポートしているため、変換をスキップします"
|
||||
}
|
||||
@@ -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 형식을 기본적으로 지원하므로 변환을 건너뜁니다."
|
||||
}
|
||||
}
|
||||
@@ -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": "英寸",
|
||||
|
||||
@@ -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 格式,跳過轉換"
|
||||
}
|
||||
}
|
||||
@@ -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
11
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user