refactor: update ErrorElement and HistogramChart components for improved styling and functionality

- Enhanced the ErrorElement component by adjusting the layout for better centering and added select-text class for improved text selection.
- Modified the HistogramChart component to accept a thumbnail URL instead of tone analysis, streamlining the data flow.
- Refactored the histogram calculation logic to improve performance and clarity, including compression of histogram data.
- Updated the manifest version from 'v2' to 'v3' across multiple files to reflect the new data structure.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-06-29 11:28:23 +08:00
parent 48aec690da
commit 3f21435271
16 changed files with 277 additions and 623 deletions

View File

@@ -36,11 +36,11 @@ export function ErrorElement() {
return ( return (
<div className="m-auto flex min-h-full max-w-prose flex-col p-8 pt-12 select-text"> <div className="m-auto flex min-h-full max-w-prose flex-col p-8 pt-12 select-text">
<div className="fixed inset-x-0 top-0 h-12" /> <div className="fixed inset-x-0 top-0 h-12" />
<div className="center flex flex-col"> <div className="flex flex-col items-center justify-center">
<i className="i-mingcute-bug-fill size-12 text-red-400" /> <i className="i-mingcute-bug-fill size-12 text-red-400" />
<h2 className="mt-12 text-2xl">{t('error.title')}</h2> <h2 className="mt-12 text-2xl select-text">{t('error.title')}</h2>
</div> </div>
<h3 className="text-xl">{message}</h3> <h3 className="text-xl select-text">{message}</h3>
{import.meta.env.DEV && stack ? ( {import.meta.env.DEV && stack ? (
<div className="mt-4 cursor-text overflow-auto rounded-md bg-red-50 p-4 text-left font-mono text-sm whitespace-pre text-red-600"> <div className="mt-4 cursor-text overflow-auto rounded-md bg-red-50 p-4 text-left font-mono text-sm whitespace-pre text-red-600">
{attachOpenInEditor(stack)} {attachOpenInEditor(stack)}

View File

@@ -285,7 +285,7 @@ export const ExifPanel: FC<{
<div className="mb-2 text-xs font-medium text-white/70"> <div className="mb-2 text-xs font-medium text-white/70">
{t('exif.histogram')} {t('exif.histogram')}
</div> </div>
<HistogramChart toneAnalysis={currentPhoto.toneAnalysis} /> <HistogramChart thumbnailUrl={currentPhoto.thumbnailUrl} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,520 +1,281 @@
import type { ToneAnalysis } from '@afilmory/builder'
import { AnimatePresence, m } from 'motion/react'
import type { FC } from 'react' import type { FC } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { decompressHistogram } from '~/lib/histogram' import { cx } from '~/lib/cn'
interface HistogramChartProps { interface CompressedHistogramData {
toneAnalysis: ToneAnalysis red: number[]
className?: string green: number[]
blue: number[]
luminance: number[]
} }
interface ChannelVisibility { interface HistogramData {
red: boolean red: number[]
green: boolean green: number[]
blue: boolean blue: number[]
luminance: boolean luminance: number[]
} }
interface HoverInfo { const calculateHistogram = (imageData: ImageData): CompressedHistogramData => {
x: number const histogram: HistogramData = {
value: number red: Array.from({ length: 256 }).fill(0) as number[],
channels: { green: Array.from({ length: 256 }).fill(0) as number[],
red: number blue: Array.from({ length: 256 }).fill(0) as number[],
green: number luminance: Array.from({ length: 256 }).fill(0) as number[],
blue: number }
luminance: number
const { data } = imageData
for (let i = 0; i < data.length; i += 4) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
histogram.red[r]++
histogram.green[g]++
histogram.blue[b]++
const luminance = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b)
histogram.luminance[luminance]++
}
const compress = (channelData: number[]): number[] => {
const compressed = Array.from({ length: 128 }).fill(0) as number[]
for (let i = 0; i < 256; i++) {
compressed[Math.floor(i / 2)] += channelData[i]
}
return compressed
}
return {
red: compress(histogram.red),
green: compress(histogram.green),
blue: compress(histogram.blue),
luminance: compress(histogram.luminance),
} }
} }
export const HistogramChart: FC<HistogramChartProps> = ({ const drawHistogram = (
toneAnalysis, canvas: HTMLCanvasElement,
className = '', histogram: CompressedHistogramData,
}) => { ) => {
const { t } = useTranslation() const ctx = canvas.getContext('2d')
const canvasRef = useRef<HTMLCanvasElement>(null) if (!ctx) return
const [channelVisibility, setChannelVisibility] = useState<ChannelVisibility>(
{ // 获取 Canvas 的实际显示尺寸
red: true, const rect = canvas.getBoundingClientRect()
green: true, const { width } = rect
blue: true, const { height } = rect
luminance: true, const dpr = window.devicePixelRatio || 1
},
// 设置高分辨率
canvas.width = width * dpr
canvas.height = height * dpr
ctx.scale(dpr, dpr)
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
// 清空画布
ctx.clearRect(0, 0, width, height)
// 找到最大值用于归一化
const maxVal = Math.max(
...histogram.luminance,
...histogram.red,
...histogram.green,
...histogram.blue,
) )
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null)
const [showStats, setShowStats] = useState(false)
const toggleChannel = useCallback((channel: keyof ChannelVisibility) => { if (maxVal === 0) return
setChannelVisibility((prev) => ({
...prev,
[channel]: !prev[channel],
}))
}, [])
const handleMouseMove = useCallback( const padding = 0
(event: React.MouseEvent<HTMLCanvasElement>) => { const chartWidth = width - padding * 2
const canvas = canvasRef.current const chartHeight = height - padding * 2
if (!canvas) return
const rect = canvas.getBoundingClientRect() // Apple 风格的颜色定义
const x = event.clientX - rect.left const colors = {
const padding = 8 red: 'rgb(255, 105, 97)',
const drawWidth = rect.width - padding * 2 green: 'rgb(52, 199, 89)',
blue: 'rgb(64, 156, 255)',
luminance: 'rgba(255, 255, 255, 0.6)',
background: 'rgba(28, 28, 30, 0.95)',
grid: 'rgba(255, 255, 255, 0.04)',
border: 'rgba(255, 255, 255, 0.08)',
}
// 计算对应的直方图索引 // 绘制背景
const histogramIndex = Math.floor(((x - padding) / drawWidth) * 256) ctx.fillStyle = colors.background
ctx.fillRect(0, 0, width, height)
if (histogramIndex >= 0 && histogramIndex < 256) { // 绘制极简网格
const decompressedHistogram = decompressHistogram( ctx.strokeStyle = colors.grid
toneAnalysis.histogram, ctx.lineWidth = 0.5
)
const { red, green, blue, luminance } = decompressedHistogram
setHoverInfo({ // 只绘制几条关键的网格线
x: histogramIndex, for (let i = 1; i <= 3; i++) {
value: histogramIndex, const y = padding + (chartHeight / 4) * i
channels: { ctx.beginPath()
red: red[histogramIndex] || 0, ctx.moveTo(padding, y)
green: green[histogramIndex] || 0, ctx.lineTo(width - padding, y)
blue: blue[histogramIndex] || 0, ctx.stroke()
luminance: luminance[histogramIndex] || 0, }
},
}) // 绘制柱状图函数
const drawBars = (data: number[], color: string, alpha = 1) => {
const barWidth = chartWidth / data.length
for (const [i, datum] of data.entries()) {
const barHeight = (datum / maxVal) * chartHeight
const x = padding + i * barWidth
const y = height - padding - barHeight
// 创建渐变
const gradient = ctx.createLinearGradient(0, y, 0, height - padding)
// 正确处理颜色字符串转换
let topColor: string
let bottomColor: string
if (color.startsWith('rgba')) {
// 如果已经是 rgba 格式,替换最后的透明度值
topColor = color.replace(/[\d.]+\)$/, `${alpha})`)
bottomColor = color.replace(/[\d.]+\)$/, `${alpha * 0.1})`)
} else if (color.startsWith('rgb')) {
// 如果是 rgb 格式,转换为 rgba
topColor = color.replace('rgb', 'rgba').replace(')', `, ${alpha})`)
bottomColor = color
.replace('rgb', 'rgba')
.replace(')', `, ${alpha * 0.1})`)
} else {
// 其他格式直接使用
topColor = color
bottomColor = color
} }
},
[toneAnalysis],
)
const handleMouseLeave = useCallback(() => { gradient.addColorStop(0, topColor)
setHoverInfo(null) gradient.addColorStop(1, bottomColor)
}, [])
ctx.fillStyle = gradient
ctx.fillRect(x, y, barWidth * 0.8, barHeight)
}
}
// 先绘制亮度通道作为背景
drawBars(histogram.luminance, colors.luminance, 0.3)
// 设置混合模式
ctx.globalCompositeOperation = 'screen'
// 绘制 RGB 通道
drawBars(histogram.red, colors.red, 0.7)
drawBars(histogram.green, colors.green, 0.7)
drawBars(histogram.blue, colors.blue, 0.7)
// 重置混合模式
ctx.globalCompositeOperation = 'source-over'
// 绘制边框
ctx.strokeStyle = colors.border
ctx.lineWidth = 1
ctx.strokeRect(padding - 0.5, padding - 0.5, chartWidth + 1, chartHeight + 1)
// 添加顶部高光
const highlightGradient = ctx.createLinearGradient(0, 0, 0, height * 0.2)
highlightGradient.addColorStop(0, 'rgba(255, 255, 255, 0.03)')
highlightGradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
ctx.fillStyle = highlightGradient
ctx.fillRect(0, 0, width, height * 0.2)
}
export const HistogramChart: FC<{
thumbnailUrl: string
className?: string
}> = ({ thumbnailUrl, className = '' }) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [histogram, setHistogram] = useState<CompressedHistogramData | null>(
null,
)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const { t } = useTranslation()
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current setLoading(true)
if (!canvas) return setError(false)
setHistogram(null)
const ctx = canvas.getContext('2d') const img = new Image()
if (!ctx) return img.crossOrigin = 'Anonymous'
img.src = thumbnailUrl
// 解压缩直方图数据用于渲染 img.onload = () => {
const decompressedHistogram = decompressHistogram(toneAnalysis.histogram) const canvas = document.createElement('canvas')
const { red, green, blue, luminance } = decompressedHistogram const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) {
// 获取设备像素比,提高画布分辨率 setError(true)
const devicePixelRatio = window.devicePixelRatio || 1 setLoading(false)
const rect = canvas.getBoundingClientRect() return
// 设置画布内部分辨率
canvas.width = rect.width * devicePixelRatio
canvas.height = rect.height * devicePixelRatio
// 缩放上下文以匹配设备像素比
ctx.scale(devicePixelRatio, devicePixelRatio)
// 设置画布样式尺寸
canvas.style.width = `${rect.width}px`
canvas.style.height = `${rect.height}px`
// 启用抗锯齿和平滑渲染
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
const { width } = rect
const { height } = rect
const padding = 8
// 清空画布
ctx.clearRect(0, 0, width, height)
// 计算最大值用于归一化
const maxRed = Math.max(...red)
const maxGreen = Math.max(...green)
const maxBlue = Math.max(...blue)
const maxLuminance = Math.max(...luminance)
const globalMax = Math.max(maxRed, maxGreen, maxBlue, maxLuminance)
if (globalMax === 0) return
const drawWidth = width - padding * 2
const drawHeight = height - padding * 2
const barWidth = drawWidth / 256
// 绘制背景网格
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'
ctx.lineWidth = 0.5
// 垂直网格线
for (let i = 0; i <= 4; i++) {
const x = padding + (i * drawWidth) / 4
ctx.beginPath()
ctx.moveTo(x, padding)
ctx.lineTo(x, height - padding)
ctx.stroke()
}
// 水平网格线
for (let i = 0; i <= 4; i++) {
const y = padding + (i * drawHeight) / 4
ctx.beginPath()
ctx.moveTo(padding, y)
ctx.lineTo(width - padding, y)
ctx.stroke()
}
// 绘制直方图函数
const drawHistogram = (data: number[], color: string, alpha = 0.6) => {
ctx.globalAlpha = alpha
// 使用路径绘制更平滑的直方图
ctx.beginPath()
ctx.moveTo(padding, height - padding)
for (const [i, datum] of data.entries()) {
const x = padding + i * barWidth
const barHeight = (datum / globalMax) * drawHeight
const y = height - padding - barHeight
ctx.lineTo(x, y)
} }
ctx.lineTo(padding + drawWidth, height - padding) // 为了更好的性能,缩放图片到合适的大小
ctx.closePath() const maxSize = 300
const scale = Math.min(
maxSize / img.naturalWidth,
maxSize / img.naturalHeight,
)
const scaledWidth = Math.floor(img.naturalWidth * scale)
const scaledHeight = Math.floor(img.naturalHeight * scale)
ctx.fillStyle = color canvas.width = scaledWidth
ctx.fill() canvas.height = scaledHeight
ctx.drawImage(img, 0, 0, scaledWidth, scaledHeight)
// 添加描边以增强视觉效果 try {
ctx.strokeStyle = color const imageData = ctx.getImageData(0, 0, scaledWidth, scaledHeight)
ctx.lineWidth = 0.5 const calculatedHistogram = calculateHistogram(imageData)
ctx.globalAlpha = alpha * 1.5 setHistogram(calculatedHistogram)
ctx.stroke() } catch (e) {
} console.error('Error calculating histogram:', e)
setError(true)
// 根据可见性绘制 RGB 直方图 } finally {
if (channelVisibility.red) { setLoading(false)
drawHistogram(red, 'rgba(255, 99, 99, 0.6)', 0.6)
}
if (channelVisibility.green) {
drawHistogram(green, 'rgba(99, 255, 99, 0.6)', 0.6)
}
if (channelVisibility.blue) {
drawHistogram(blue, 'rgba(99, 99, 255, 0.6)', 0.6)
}
// 绘制亮度直方图
if (channelVisibility.luminance) {
ctx.globalAlpha = 0.8
// 使用路径绘制更平滑的亮度直方图
ctx.beginPath()
ctx.moveTo(padding, height - padding)
for (const [i, element] of luminance.entries()) {
const x = padding + i * barWidth
const barHeight = (element / globalMax) * drawHeight
const y = height - padding - barHeight
ctx.lineTo(x, y)
} }
ctx.lineTo(padding + drawWidth, height - padding)
ctx.closePath()
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
ctx.fill()
// 添加描边
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'
ctx.lineWidth = 0.5
ctx.globalAlpha = 0.9
ctx.stroke()
} }
// 重置透明度 img.onerror = () => {
ctx.globalAlpha = 1 setError(true)
setLoading(false)
// 绘制阴影和高光区域标记
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'
ctx.lineWidth = 1
ctx.setLineDash([2, 2])
// 阴影区域 (0-85)
const shadowEnd = padding + (85 * drawWidth) / 255
ctx.beginPath()
ctx.moveTo(shadowEnd, padding)
ctx.lineTo(shadowEnd, height - padding)
ctx.stroke()
// 高光区域 (170-255)
const highlightStart = padding + (170 * drawWidth) / 255
ctx.beginPath()
ctx.moveTo(highlightStart, padding)
ctx.lineTo(highlightStart, height - padding)
ctx.stroke()
ctx.setLineDash([])
// 绘制悬停指示线
if (hoverInfo) {
const barWidth = drawWidth / 256
const hoverX = padding + hoverInfo.x * barWidth
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'
ctx.lineWidth = 1
ctx.setLineDash([])
ctx.beginPath()
ctx.moveTo(hoverX, padding)
ctx.lineTo(hoverX, height - padding)
ctx.stroke()
} }
}, [toneAnalysis, channelVisibility, hoverInfo]) }, [thumbnailUrl])
// 计算统计信息 useEffect(() => {
const getStatistics = () => { if (histogram && canvasRef.current) {
const decompressedHistogram = decompressHistogram(toneAnalysis.histogram) drawHistogram(canvasRef.current, histogram)
const { red, green, blue, luminance } = decompressedHistogram
const calculateStats = (data: number[]) => {
const total = data.reduce((sum, val) => sum + val, 0)
if (total === 0) return { mean: 0, median: 0, mode: 0 }
let weightedSum = 0
let count = 0
for (const [i, datum] of data.entries()) {
weightedSum += i * datum
count += datum
}
const mean = Math.round(weightedSum / count)
// 找到中位数
let cumulative = 0
let median = 0
for (const [i, datum] of data.entries()) {
cumulative += datum
if (cumulative >= count / 2) {
median = i
break
}
}
// 找到众数
const maxValue = Math.max(...data)
const mode = data.indexOf(maxValue)
return { mean, median, mode }
} }
}, [histogram])
return {
luminance: calculateStats(luminance),
red: calculateStats(red),
green: calculateStats(green),
blue: calculateStats(blue),
}
}
const stats = getStatistics()
return ( return (
<div className={`relative ${className}`}> <div className={cx('relative grow w-full h-32 group', className)}>
{/* 主画布 */} {loading && (
<div className="relative"> <div className="bg-material-ultra-thin absolute inset-0 flex items-center justify-center rounded-sm backdrop-blur-xl">
<canvas <div className="i-mingcute-loading-3-line animate-spin text-xl" />
ref={canvasRef} </div>
className="h-[100px] w-full cursor-crosshair rounded-md bg-black/20" )}
style={{ imageRendering: 'auto' }} {error && (
onMouseMove={handleMouseMove} <div className="bg-material-ultra-thin absolute inset-0 flex items-center justify-center rounded-sm backdrop-blur-xl">
onMouseLeave={handleMouseLeave} <div className="text-center">
/> <div className="text-text-secondary text-xs">
{t('photo.error.loading')}
{/* 悬停信息 */}
{hoverInfo && (
<m.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="pointer-events-none absolute top-2 left-2 rounded bg-black/80 px-2 py-1 text-xs text-white backdrop-blur-sm"
>
<div>
{t('histogram.value')}: {hoverInfo.value}
</div> </div>
{channelVisibility.red && (
<div className="text-red">
R: {hoverInfo.channels.red.toFixed(2)}
</div>
)}
{channelVisibility.green && (
<div className="text-green">
G: {hoverInfo.channels.green.toFixed(2)}
</div>
)}
{channelVisibility.blue && (
<div className="text-blue">
B: {hoverInfo.channels.blue.toFixed(2)}
</div>
)}
{channelVisibility.luminance && (
<div className="text-white">
{t('histogram.luminance')}:{' '}
{hoverInfo.channels.luminance.toFixed(2)}
</div>
)}
</m.div>
)}
</div>
{/* 控制面板 */}
<div className="mt-2 space-y-2">
{/* 通道切换 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => toggleChannel('red')}
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-all ${
channelVisibility.red
? 'text-red border border-red-500/30 bg-red-500/20'
: 'border border-white/10 bg-white/5 text-white/40'
}`}
>
<div
className={`size-2 rounded-full ${channelVisibility.red ? 'bg-red' : 'bg-white/20'}`}
/>
<span>R</span>
</button>
<button
type="button"
onClick={() => toggleChannel('green')}
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-all ${
channelVisibility.green
? 'border border-green-500/30 bg-green-500/20 text-green-400'
: 'border border-white/10 bg-white/5 text-white/40'
}`}
>
<div
className={`size-2 rounded-full ${channelVisibility.green ? 'bg-green' : 'bg-white/20'}`}
/>
<span>G</span>
</button>
<button
type="button"
onClick={() => toggleChannel('blue')}
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-all ${
channelVisibility.blue
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'border border-white/10 bg-white/5 text-white/40'
}`}
>
<div
className={`size-2 rounded-full ${channelVisibility.blue ? 'bg-blue' : 'bg-white/20'}`}
/>
<span>B</span>
</button>
<button
type="button"
onClick={() => toggleChannel('luminance')}
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-all ${
channelVisibility.luminance
? 'border border-white/30 bg-white/20 text-white'
: 'border border-white/10 bg-white/5 text-white/40'
}`}
>
<div
className={`size-2 rounded-full ${channelVisibility.luminance ? 'bg-white/80' : 'bg-white/20'}`}
/>
<span>{t('histogram.luminance')}</span>
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowStats(!showStats)}
className="rounded border border-white/10 bg-white/5 px-2 py-1 text-xs text-white/60 transition-all hover:bg-white/10"
>
{t('histogram.statistics')}
</button>
</div> </div>
</div> </div>
)}
{/* 统计信息 */} {histogram && (
<AnimatePresence> <canvas
{showStats && ( ref={canvasRef}
<m.div className="bg-material-ultra-thin ring-fill-tertiary/20 group-hover:ring-fill-tertiary/40 h-full w-full rounded-sm ring-1 backdrop-blur-xl transition-all duration-200"
initial={{ opacity: 0, height: 0 }} />
animate={{ opacity: 1, height: 'auto' }} )}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden rounded bg-black/20 text-xs text-white/80"
>
<div className="grid grid-cols-4 gap-2 p-2">
<div className="text-center">
<div className="mb-1 text-white/60">
{t('histogram.channel')}
</div>
<div className="space-y-1">
{channelVisibility.luminance && (
<div className="text-white">
{t('histogram.luminance')}
</div>
)}
{channelVisibility.red && (
<div className="text-red-400">{t('histogram.red')}</div>
)}
{channelVisibility.green && (
<div className="text-green-400">
{t('histogram.green')}
</div>
)}
{channelVisibility.blue && (
<div className="text-blue-400">{t('histogram.blue')}</div>
)}
</div>
</div>
<div className="text-center">
<div className="mb-1 text-white/60">
{t('histogram.mean')}
</div>
<div className="space-y-1">
{channelVisibility.luminance && (
<div>{stats.luminance.mean}</div>
)}
{channelVisibility.red && <div>{stats.red.mean}</div>}
{channelVisibility.green && <div>{stats.green.mean}</div>}
{channelVisibility.blue && <div>{stats.blue.mean}</div>}
</div>
</div>
<div className="text-center">
<div className="mb-1 text-white/60">
{t('histogram.median')}
</div>
<div className="space-y-1">
{channelVisibility.luminance && (
<div>{stats.luminance.median}</div>
)}
{channelVisibility.red && <div>{stats.red.median}</div>}
{channelVisibility.green && <div>{stats.green.median}</div>}
{channelVisibility.blue && <div>{stats.blue.median}</div>}
</div>
</div>
<div className="text-center">
<div className="mb-1 text-white/60">
{t('histogram.mode')}
</div>
<div className="space-y-1">
{channelVisibility.luminance && (
<div>{stats.luminance.mode}</div>
)}
{channelVisibility.red && <div>{stats.red.mode}</div>}
{channelVisibility.green && <div>{stats.green.mode}</div>}
{channelVisibility.blue && <div>{stats.blue.mode}</div>}
</div>
</div>
</div>
</m.div>
)}
</AnimatePresence>
</div>
</div> </div>
) )
} }

View File

@@ -192,7 +192,7 @@ export const Component = () => {
const photos = photoLoader.getPhotos() const photos = photoLoader.getPhotos()
const manifestData = { const manifestData = {
version: 'v2', version: 'v3',
data: photos, data: photos,
} }
@@ -355,7 +355,7 @@ export const Component = () => {
<JsonHighlight <JsonHighlight
data={ data={
searchTerm searchTerm
? { version: 'v2', data: filteredPhotos } ? { version: 'v3', data: filteredPhotos }
: manifestData : manifestData
} }
/> />

View File

@@ -209,16 +209,6 @@
"gallery.built.at": "Built at ", "gallery.built.at": "Built at ",
"gallery.photos_one": "{{count}} photo", "gallery.photos_one": "{{count}} photo",
"gallery.photos_other": "{{count}} photos", "gallery.photos_other": "{{count}} photos",
"histogram.blue": "Blue",
"histogram.channel": "Channel",
"histogram.green": "Green",
"histogram.luminance": "Lu",
"histogram.mean": "Mean",
"histogram.median": "Median",
"histogram.mode": "Mode",
"histogram.red": "Red",
"histogram.statistics": "Statistics",
"histogram.value": "Value",
"loading.converting": "Converting...", "loading.converting": "Converting...",
"loading.default": "Loading", "loading.default": "Loading",
"loading.heic.converting": "Converting HEIC/HEIF image format...", "loading.heic.converting": "Converting HEIC/HEIF image format...",

View File

@@ -208,16 +208,6 @@
"gallery.built.at": "ビルド日時 ", "gallery.built.at": "ビルド日時 ",
"gallery.photos_one": "写真{{count}}枚", "gallery.photos_one": "写真{{count}}枚",
"gallery.photos_other": "写真{{count}}枚", "gallery.photos_other": "写真{{count}}枚",
"histogram.blue": "青",
"histogram.channel": "チャンネル",
"histogram.green": "緑",
"histogram.luminance": "輝度",
"histogram.mean": "平均値",
"histogram.median": "中央値",
"histogram.mode": "最頻値",
"histogram.red": "赤",
"histogram.statistics": "統計",
"histogram.value": "値",
"loading.converting": "変換中...", "loading.converting": "変換中...",
"loading.default": "読み込み中", "loading.default": "読み込み中",
"loading.heic.converting": "HEIC/HEIF 画像フォーマットを変換中...", "loading.heic.converting": "HEIC/HEIF 画像フォーマットを変換中...",
@@ -233,10 +223,10 @@
"photo.error.loading": "画像の読み込みに失敗しました", "photo.error.loading": "画像の読み込みに失敗しました",
"photo.live.badge": "Live", "photo.live.badge": "Live",
"photo.live.converting.detail": "{{method}}を使用してビデオ形式を変換しています...", "photo.live.converting.detail": "{{method}}を使用してビデオ形式を変換しています...",
"photo.live.converting.video": "Live Photoのビデオを変換中", "photo.live.converting.video": "Live Photo のビデオを変換中",
"photo.live.playing": "ライブ写真を再生中", "photo.live.playing": "ライブ写真を再生中",
"photo.live.tooltip.desktop.main": "ホバーしてLive Photoを再生", "photo.live.tooltip.desktop.main": "ホバーして Live Photo を再生",
"photo.live.tooltip.desktop.zoom": "ホバーしてLive Photoを再生 / ダブルクリックしてズーム", "photo.live.tooltip.desktop.zoom": "ホバーして Live Photo を再生 / ダブルクリックしてズーム",
"photo.live.tooltip.mobile.main": "長押ししてライブフォトを再生", "photo.live.tooltip.mobile.main": "長押ししてライブフォトを再生",
"photo.live.tooltip.mobile.zoom": "長押ししてライブフォトを再生/ダブルタップしてズーム", "photo.live.tooltip.mobile.zoom": "長押ししてライブフォトを再生/ダブルタップしてズーム",
"photo.share.actions": "アクション", "photo.share.actions": "アクション",

View File

@@ -172,10 +172,10 @@
"exif.sensing.method.color.sequential.linear": "컬러 순차 선형 센서", "exif.sensing.method.color.sequential.linear": "컬러 순차 선형 센서",
"exif.sensing.method.color.sequential.main": "컬러 순차 영역 센서", "exif.sensing.method.color.sequential.main": "컬러 순차 영역 센서",
"exif.sensing.method.one-chip-color-area": "1 칩 컬러 영역 센서", "exif.sensing.method.one-chip-color-area": "1 칩 컬러 영역 센서",
"exif.sensing.method.one.chip": "1칩 컬러 영역 센서", "exif.sensing.method.one.chip": "1 칩 컬러 영역 센서",
"exif.sensing.method.three.chip": "3칩 컬러 영역 센서", "exif.sensing.method.three.chip": "3 칩 컬러 영역 센서",
"exif.sensing.method.trilinear": "삼선형 센서", "exif.sensing.method.trilinear": "삼선형 센서",
"exif.sensing.method.two.chip": "2칩 컬러 영역 센서", "exif.sensing.method.two.chip": "2 칩 컬러 영역 센서",
"exif.sensing.method.type": "감지 방식", "exif.sensing.method.type": "감지 방식",
"exif.sensing.method.undefined": "정의되지 않음", "exif.sensing.method.undefined": "정의되지 않음",
"exif.shadow.ratio": "섀도우 비율", "exif.shadow.ratio": "섀도우 비율",
@@ -208,16 +208,6 @@
"gallery.built.at": "빌드 날짜 ", "gallery.built.at": "빌드 날짜 ",
"gallery.photos_one": "사진 {{count}}장", "gallery.photos_one": "사진 {{count}}장",
"gallery.photos_other": "사진 {{count}}장", "gallery.photos_other": "사진 {{count}}장",
"histogram.blue": "파랑",
"histogram.channel": "채널",
"histogram.green": "초록",
"histogram.luminance": "휘도",
"histogram.mean": "평균",
"histogram.median": "중간값",
"histogram.mode": "최빈값",
"histogram.red": "빨강",
"histogram.statistics": "통계",
"histogram.value": "값",
"loading.converting": "변환 중...", "loading.converting": "변환 중...",
"loading.default": "로딩 중", "loading.default": "로딩 중",
"loading.heic.converting": "HEIC/HEIF 이미지 형식 변환 중...", "loading.heic.converting": "HEIC/HEIF 이미지 형식 변환 중...",

View File

@@ -209,16 +209,6 @@
"gallery.built.at": "构建于 ", "gallery.built.at": "构建于 ",
"gallery.photos_one": "{{count}} 张照片", "gallery.photos_one": "{{count}} 张照片",
"gallery.photos_other": "{{count}} 张照片", "gallery.photos_other": "{{count}} 张照片",
"histogram.blue": "蓝",
"histogram.channel": "通道",
"histogram.green": "绿",
"histogram.luminance": "亮度",
"histogram.mean": "平均值",
"histogram.median": "中位数",
"histogram.mode": "众数",
"histogram.red": "红",
"histogram.statistics": "统计",
"histogram.value": "值",
"loading.converting": "转换中...", "loading.converting": "转换中...",
"loading.default": "加载中", "loading.default": "加载中",
"loading.heic.converting": "正在转换 HEIC/HEIF 图像格式...", "loading.heic.converting": "正在转换 HEIC/HEIF 图像格式...",

View File

@@ -202,22 +202,12 @@
"exif.white.balance.kelvin": "手動色溫", "exif.white.balance.kelvin": "手動色溫",
"exif.white.balance.manual": "手動", "exif.white.balance.manual": "手動",
"exif.white.balance.red": "紅色", "exif.white.balance.red": "紅色",
"exif.white.balance.shift.ab": "白平衡偏移 (琥珀色-藍色)", "exif.white.balance.shift.ab": "白平衡偏移 (琥珀色 - 藍色)",
"exif.white.balance.shift.gm": "白平衡偏移 (綠色-洋紅色)", "exif.white.balance.shift.gm": "白平衡偏移 (綠色 - 洋紅色)",
"exif.white.balance.title": "白平衡", "exif.white.balance.title": "白平衡",
"gallery.built.at": "建置於 ", "gallery.built.at": "建置於 ",
"gallery.photos_one": "{{count}} 張照片", "gallery.photos_one": "{{count}} 張照片",
"gallery.photos_other": "{{count}} 張照片", "gallery.photos_other": "{{count}} 張照片",
"histogram.blue": "藍",
"histogram.channel": "通道",
"histogram.green": "綠",
"histogram.luminance": "亮度",
"histogram.mean": "平均值",
"histogram.median": "中位數",
"histogram.mode": "眾數",
"histogram.red": "紅",
"histogram.statistics": "統計",
"histogram.value": "值",
"loading.converting": "轉換中...", "loading.converting": "轉換中...",
"loading.default": "載入中", "loading.default": "載入中",
"loading.heic.converting": "正在轉換 HEIC/HEIF 圖像格式...", "loading.heic.converting": "正在轉換 HEIC/HEIF 圖像格式...",

View File

@@ -208,16 +208,6 @@
"gallery.built.at": "建置於 ", "gallery.built.at": "建置於 ",
"gallery.photos_one": "{{count}} 張照片", "gallery.photos_one": "{{count}} 張照片",
"gallery.photos_other": "{{count}} 張照片", "gallery.photos_other": "{{count}} 張照片",
"histogram.blue": "藍",
"histogram.channel": "通道",
"histogram.green": "綠",
"histogram.luminance": "亮度",
"histogram.mean": "平均值",
"histogram.median": "中位數",
"histogram.mode": "眾數",
"histogram.red": "紅",
"histogram.statistics": "統計",
"histogram.value": "值",
"loading.converting": "轉換中...", "loading.converting": "轉換中...",
"loading.default": "載入中", "loading.default": "載入中",
"loading.heic.converting": "正在轉換 HEIC/HEIF 圖像格式...", "loading.heic.converting": "正在轉換 HEIC/HEIF 圖像格式...",

View File

@@ -312,7 +312,7 @@ class PhotoGalleryBuilder {
): Promise<AfilmoryManifest> { ): Promise<AfilmoryManifest> {
return options.isForceMode || options.isForceManifest return options.isForceMode || options.isForceManifest
? { ? {
version: 'v2', version: 'v3',
data: [], data: [],
} }
: await loadExistingManifest() : await loadExistingManifest()

View File

@@ -1,44 +1,11 @@
import type { Logger } from '@afilmory/builder/logger/index.js'
import type { import type {
CompressedHistogramData,
HistogramData, HistogramData,
ToneAnalysis, ToneAnalysis,
ToneType, ToneType,
} from '@afilmory/builder/types/photo.js' } from '@afilmory/builder/types/photo.js'
import type sharp from 'sharp' import type sharp from 'sharp'
/** import { getGlobalLoggers } from '../photo'
* 将 256 点位直方图压缩到 64 点位
* @param histogram 原始直方图数据
* @returns 压缩后的直方图数据
*/
function compressHistogram(histogram: HistogramData): CompressedHistogramData {
const compressChannel = (data: number[]): number[] => {
const compressed: number[] = []
const groupSize = 4 // 256 / 64 = 4每 4 个点合并为 1 个点
for (let i = 0; i < 64; i++) {
let sum = 0
for (let j = 0; j < groupSize; j++) {
const index = i * groupSize + j
if (index < data.length) {
sum += data[index]
}
}
// 量化为整数 (0-10000)保留4位小数精度
compressed[i] = Math.round(sum * 10000)
}
return compressed
}
return {
red: compressChannel(histogram.red),
green: compressChannel(histogram.green),
blue: compressChannel(histogram.blue),
luminance: compressChannel(histogram.luminance),
}
}
/** /**
* 计算图片的直方图 * 计算图片的直方图
@@ -48,9 +15,8 @@ function compressHistogram(histogram: HistogramData): CompressedHistogramData {
*/ */
async function calculateHistogram( async function calculateHistogram(
sharpInstance: sharp.Sharp, sharpInstance: sharp.Sharp,
imageLogger?: Logger['image'],
): Promise<HistogramData | null> { ): Promise<HistogramData | null> {
const log = imageLogger const log = getGlobalLoggers().image
try { try {
log?.info('开始计算图片直方图') log?.info('开始计算图片直方图')
@@ -114,11 +80,8 @@ async function calculateHistogram(
* @param imageLogger 日志记录器 * @param imageLogger 日志记录器
* @returns 影调分析结果 * @returns 影调分析结果
*/ */
function analyzeTone( function analyzeTone(histogram: HistogramData): ToneAnalysis {
histogram: HistogramData, const log = getGlobalLoggers().image
imageLogger?: Logger['image'],
): ToneAnalysis {
const log = imageLogger
try { try {
log?.info('开始分析图片影调') log?.info('开始分析图片影调')
@@ -180,7 +143,6 @@ function analyzeTone(
contrast, contrast,
shadowRatio: Math.round(shadowRatio * 100) / 100, shadowRatio: Math.round(shadowRatio * 100) / 100,
highlightRatio: Math.round(highlightRatio * 100) / 100, highlightRatio: Math.round(highlightRatio * 100) / 100,
histogram: compressHistogram(histogram),
} }
log?.success( log?.success(
@@ -189,21 +151,15 @@ function analyzeTone(
return result return result
} catch (error) { } catch (error) {
log?.error('分析影调失败:', error) log.error('分析影调失败:', error)
// 返回默认值 // 返回默认值
const defaultHistogram: CompressedHistogramData = {
red: Array.from({ length: 64 }).fill(0) as number[],
green: Array.from({ length: 64 }).fill(0) as number[],
blue: Array.from({ length: 64 }).fill(0) as number[],
luminance: Array.from({ length: 64 }).fill(0) as number[],
}
return { return {
toneType: 'normal', toneType: 'normal',
brightness: 50, brightness: 50,
contrast: 50, contrast: 50,
shadowRatio: 0.33, shadowRatio: 0.33,
highlightRatio: 0.33, highlightRatio: 0.33,
histogram: defaultHistogram,
} }
} }
} }
@@ -216,12 +172,11 @@ function analyzeTone(
*/ */
export async function calculateHistogramAndAnalyzeTone( export async function calculateHistogramAndAnalyzeTone(
sharpInstance: sharp.Sharp, sharpInstance: sharp.Sharp,
imageLogger?: Logger['image'],
): Promise<ToneAnalysis | null> { ): Promise<ToneAnalysis | null> {
const histogram = await calculateHistogram(sharpInstance, imageLogger) const histogram = await calculateHistogram(sharpInstance)
if (!histogram) { if (!histogram) {
return null return null
} }
return analyzeTone(histogram, imageLogger) return analyzeTone(histogram)
} }

View File

@@ -11,22 +11,28 @@ import type { PhotoManifestItem } from '../types/photo.js'
const manifestPath = path.join(workdir, 'src/data/photos-manifest.json') const manifestPath = path.join(workdir, 'src/data/photos-manifest.json')
export async function loadExistingManifest(): Promise<AfilmoryManifest> { export async function loadExistingManifest(): Promise<AfilmoryManifest> {
let manifest: AfilmoryManifest
try { try {
const manifestContent = await fs.readFile(manifestPath, 'utf-8') const manifestContent = await fs.readFile(manifestPath, 'utf-8')
const manifest = JSON.parse(manifestContent) as AfilmoryManifest manifest = JSON.parse(manifestContent) as AfilmoryManifest
if (manifest.version !== 'v2') {
throw new Error('Invalid manifest version')
}
return manifest
} catch { } catch {
logger.fs.error( logger.fs.error(
'🔍 未找到 manifest 文件/解析失败,创建新的 manifest 文件...', '🔍 未找到 manifest 文件/解析失败,创建新的 manifest 文件...',
) )
return { return {
version: 'v2', version: 'v3',
data: [], data: [],
} }
} }
if (manifest.version !== 'v3') {
logger.fs.error('🔍 无效的 manifest 版本,创建新的 manifest 文件...')
return {
version: 'v3',
data: [],
}
}
return manifest
} }
// 检查照片是否需要更新(基于最后修改时间) // 检查照片是否需要更新(基于最后修改时间)
@@ -55,7 +61,7 @@ export async function saveManifest(items: PhotoManifestItem[]): Promise<void> {
manifestPath, manifestPath,
JSON.stringify( JSON.stringify(
{ {
version: 'v2', version: 'v3',
data: sortedManifest, data: sortedManifest,
} as AfilmoryManifest, } as AfilmoryManifest,
null, null,

View File

@@ -76,10 +76,6 @@ export async function processThumbnailAndBlurhash(
width, width,
height, height,
options.isForceMode || options.isForceThumbnails, options.isForceMode || options.isForceThumbnails,
{
thumbnail: loggers.thumbnail.originalLogger,
blurhash: loggers.blurhash.originalLogger,
},
) )
return { return {
@@ -140,8 +136,5 @@ export async function processToneAnalysis(
} }
// 计算新的影调分析 // 计算新的影调分析
return await calculateHistogramAndAnalyzeTone( return await calculateHistogramAndAnalyzeTone(sharpInstance)
sharpInstance,
loggers.tone.originalLogger,
)
} }

View File

@@ -1,6 +1,6 @@
import type { PhotoManifestItem } from './photo' import type { PhotoManifestItem } from './photo'
export type AfilmoryManifest = { export type AfilmoryManifest = {
version: 'v2' version: 'v3'
data: PhotoManifestItem[] data: PhotoManifestItem[]
} }

View File

@@ -26,7 +26,6 @@ export interface ToneAnalysis {
contrast: number // 0-100对比度 contrast: number // 0-100对比度
shadowRatio: number // 0-1阴影区域占比 shadowRatio: number // 0-1阴影区域占比
highlightRatio: number // 0-1高光区域占比 highlightRatio: number // 0-1高光区域占比
histogram: CompressedHistogramData // 压缩的直方图数据
} }
export interface PhotoInfo { export interface PhotoInfo {