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 (
<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="center flex flex-col">
<div className="flex flex-col items-center justify-center">
<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>
<h3 className="text-xl">{message}</h3>
<h3 className="text-xl select-text">{message}</h3>
{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">
{attachOpenInEditor(stack)}

View File

@@ -285,7 +285,7 @@ export const ExifPanel: FC<{
<div className="mb-2 text-xs font-medium text-white/70">
{t('exif.histogram')}
</div>
<HistogramChart toneAnalysis={currentPhoto.toneAnalysis} />
<HistogramChart thumbnailUrl={currentPhoto.thumbnailUrl} />
</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 { useCallback, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { decompressHistogram } from '~/lib/histogram'
import { cx } from '~/lib/cn'
interface HistogramChartProps {
toneAnalysis: ToneAnalysis
className?: string
interface CompressedHistogramData {
red: number[]
green: number[]
blue: number[]
luminance: number[]
}
interface ChannelVisibility {
red: boolean
green: boolean
blue: boolean
luminance: boolean
interface HistogramData {
red: number[]
green: number[]
blue: number[]
luminance: number[]
}
interface HoverInfo {
x: number
value: number
channels: {
red: number
green: number
blue: number
luminance: number
const calculateHistogram = (imageData: ImageData): CompressedHistogramData => {
const histogram: HistogramData = {
red: Array.from({ length: 256 }).fill(0) as number[],
green: Array.from({ length: 256 }).fill(0) as number[],
blue: Array.from({ length: 256 }).fill(0) as number[],
luminance: Array.from({ length: 256 }).fill(0) as 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> = ({
toneAnalysis,
className = '',
}) => {
const { t } = useTranslation()
const canvasRef = useRef<HTMLCanvasElement>(null)
const [channelVisibility, setChannelVisibility] = useState<ChannelVisibility>(
{
red: true,
green: true,
blue: true,
luminance: true,
},
const drawHistogram = (
canvas: HTMLCanvasElement,
histogram: CompressedHistogramData,
) => {
const ctx = canvas.getContext('2d')
if (!ctx) return
// 获取 Canvas 的实际显示尺寸
const rect = canvas.getBoundingClientRect()
const { width } = rect
const { height } = rect
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) => {
setChannelVisibility((prev) => ({
...prev,
[channel]: !prev[channel],
}))
}, [])
if (maxVal === 0) return
const handleMouseMove = useCallback(
(event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current
if (!canvas) return
const padding = 0
const chartWidth = width - padding * 2
const chartHeight = height - padding * 2
const rect = canvas.getBoundingClientRect()
const x = event.clientX - rect.left
const padding = 8
const drawWidth = rect.width - padding * 2
// Apple 风格的颜色定义
const colors = {
red: 'rgb(255, 105, 97)',
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(
toneAnalysis.histogram,
)
const { red, green, blue, luminance } = decompressedHistogram
// 绘制极简网格
ctx.strokeStyle = colors.grid
ctx.lineWidth = 0.5
setHoverInfo({
x: histogramIndex,
value: histogramIndex,
channels: {
red: red[histogramIndex] || 0,
green: green[histogramIndex] || 0,
blue: blue[histogramIndex] || 0,
luminance: luminance[histogramIndex] || 0,
},
})
// 只绘制几条关键的网格线
for (let i = 1; i <= 3; i++) {
const y = padding + (chartHeight / 4) * i
ctx.beginPath()
ctx.moveTo(padding, y)
ctx.lineTo(width - padding, y)
ctx.stroke()
}
// 绘制柱状图函数
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(() => {
setHoverInfo(null)
}, [])
gradient.addColorStop(0, topColor)
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(() => {
const canvas = canvasRef.current
if (!canvas) return
setLoading(true)
setError(false)
setHistogram(null)
const ctx = canvas.getContext('2d')
if (!ctx) return
const img = new Image()
img.crossOrigin = 'Anonymous'
img.src = thumbnailUrl
// 解压缩直方图数据用于渲染
const decompressedHistogram = decompressHistogram(toneAnalysis.histogram)
const { red, green, blue, luminance } = decompressedHistogram
// 获取设备像素比,提高画布分辨率
const devicePixelRatio = window.devicePixelRatio || 1
const rect = canvas.getBoundingClientRect()
// 设置画布内部分辨率
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)
img.onload = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) {
setError(true)
setLoading(false)
return
}
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
ctx.fill()
canvas.width = scaledWidth
canvas.height = scaledHeight
ctx.drawImage(img, 0, 0, scaledWidth, scaledHeight)
// 添加描边以增强视觉效果
ctx.strokeStyle = color
ctx.lineWidth = 0.5
ctx.globalAlpha = alpha * 1.5
ctx.stroke()
}
// 根据可见性绘制 RGB 直方图
if (channelVisibility.red) {
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)
try {
const imageData = ctx.getImageData(0, 0, scaledWidth, scaledHeight)
const calculatedHistogram = calculateHistogram(imageData)
setHistogram(calculatedHistogram)
} catch (e) {
console.error('Error calculating histogram:', e)
setError(true)
} finally {
setLoading(false)
}
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()
}
// 重置透明度
ctx.globalAlpha = 1
// 绘制阴影和高光区域标记
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()
img.onerror = () => {
setError(true)
setLoading(false)
}
}, [toneAnalysis, channelVisibility, hoverInfo])
}, [thumbnailUrl])
// 计算统计信息
const getStatistics = () => {
const decompressedHistogram = decompressHistogram(toneAnalysis.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 }
useEffect(() => {
if (histogram && canvasRef.current) {
drawHistogram(canvasRef.current, histogram)
}
return {
luminance: calculateStats(luminance),
red: calculateStats(red),
green: calculateStats(green),
blue: calculateStats(blue),
}
}
const stats = getStatistics()
}, [histogram])
return (
<div className={`relative ${className}`}>
{/* 主画布 */}
<div className="relative">
<canvas
ref={canvasRef}
className="h-[100px] w-full cursor-crosshair rounded-md bg-black/20"
style={{ imageRendering: 'auto' }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
/>
{/* 悬停信息 */}
{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 className={cx('relative grow w-full h-32 group', className)}>
{loading && (
<div className="bg-material-ultra-thin absolute inset-0 flex items-center justify-center rounded-sm backdrop-blur-xl">
<div className="i-mingcute-loading-3-line animate-spin text-xl" />
</div>
)}
{error && (
<div className="bg-material-ultra-thin absolute inset-0 flex items-center justify-center rounded-sm backdrop-blur-xl">
<div className="text-center">
<div className="text-text-secondary text-xs">
{t('photo.error.loading')}
</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>
{/* 统计信息 */}
<AnimatePresence>
{showStats && (
<m.div
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>
)}
{histogram && (
<canvas
ref={canvasRef}
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"
/>
)}
</div>
)
}

View File

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

View File

@@ -209,16 +209,6 @@
"gallery.built.at": "Built at ",
"gallery.photos_one": "{{count}} photo",
"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.default": "Loading",
"loading.heic.converting": "Converting HEIC/HEIF image format...",

View File

@@ -208,16 +208,6 @@
"gallery.built.at": "ビルド日時 ",
"gallery.photos_one": "写真{{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.default": "読み込み中",
"loading.heic.converting": "HEIC/HEIF 画像フォーマットを変換中...",
@@ -233,10 +223,10 @@
"photo.error.loading": "画像の読み込みに失敗しました",
"photo.live.badge": "Live",
"photo.live.converting.detail": "{{method}}を使用してビデオ形式を変換しています...",
"photo.live.converting.video": "Live Photoのビデオを変換中",
"photo.live.converting.video": "Live Photo のビデオを変換中",
"photo.live.playing": "ライブ写真を再生中",
"photo.live.tooltip.desktop.main": "ホバーしてLive Photoを再生",
"photo.live.tooltip.desktop.zoom": "ホバーしてLive Photoを再生 / ダブルクリックしてズーム",
"photo.live.tooltip.desktop.main": "ホバーして Live Photo を再生",
"photo.live.tooltip.desktop.zoom": "ホバーして Live Photo を再生 / ダブルクリックしてズーム",
"photo.live.tooltip.mobile.main": "長押ししてライブフォトを再生",
"photo.live.tooltip.mobile.zoom": "長押ししてライブフォトを再生/ダブルタップしてズーム",
"photo.share.actions": "アクション",

View File

@@ -172,10 +172,10 @@
"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": "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": "섀도우 비율",
@@ -208,16 +208,6 @@
"gallery.built.at": "빌드 날짜 ",
"gallery.photos_one": "사진 {{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.default": "로딩 중",
"loading.heic.converting": "HEIC/HEIF 이미지 형식 변환 중...",

View File

@@ -209,16 +209,6 @@
"gallery.built.at": "构建于 ",
"gallery.photos_one": "{{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.default": "加载中",
"loading.heic.converting": "正在转换 HEIC/HEIF 图像格式...",

View File

@@ -202,22 +202,12 @@
"exif.white.balance.kelvin": "手動色溫",
"exif.white.balance.manual": "手動",
"exif.white.balance.red": "紅色",
"exif.white.balance.shift.ab": "白平衡偏移 (琥珀色-藍色)",
"exif.white.balance.shift.gm": "白平衡偏移 (綠色-洋紅色)",
"exif.white.balance.shift.ab": "白平衡偏移 (琥珀色 - 藍色)",
"exif.white.balance.shift.gm": "白平衡偏移 (綠色 - 洋紅色)",
"exif.white.balance.title": "白平衡",
"gallery.built.at": "建置於 ",
"gallery.photos_one": "{{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.default": "載入中",
"loading.heic.converting": "正在轉換 HEIC/HEIF 圖像格式...",

View File

@@ -208,16 +208,6 @@
"gallery.built.at": "建置於 ",
"gallery.photos_one": "{{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.default": "載入中",
"loading.heic.converting": "正在轉換 HEIC/HEIF 圖像格式...",

View File

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

View File

@@ -1,44 +1,11 @@
import type { Logger } from '@afilmory/builder/logger/index.js'
import type {
CompressedHistogramData,
HistogramData,
ToneAnalysis,
ToneType,
} from '@afilmory/builder/types/photo.js'
import type sharp from 'sharp'
/**
* 将 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),
}
}
import { getGlobalLoggers } from '../photo'
/**
* 计算图片的直方图
@@ -48,9 +15,8 @@ function compressHistogram(histogram: HistogramData): CompressedHistogramData {
*/
async function calculateHistogram(
sharpInstance: sharp.Sharp,
imageLogger?: Logger['image'],
): Promise<HistogramData | null> {
const log = imageLogger
const log = getGlobalLoggers().image
try {
log?.info('开始计算图片直方图')
@@ -114,11 +80,8 @@ async function calculateHistogram(
* @param imageLogger 日志记录器
* @returns 影调分析结果
*/
function analyzeTone(
histogram: HistogramData,
imageLogger?: Logger['image'],
): ToneAnalysis {
const log = imageLogger
function analyzeTone(histogram: HistogramData): ToneAnalysis {
const log = getGlobalLoggers().image
try {
log?.info('开始分析图片影调')
@@ -180,7 +143,6 @@ function analyzeTone(
contrast,
shadowRatio: Math.round(shadowRatio * 100) / 100,
highlightRatio: Math.round(highlightRatio * 100) / 100,
histogram: compressHistogram(histogram),
}
log?.success(
@@ -189,21 +151,15 @@ function analyzeTone(
return result
} 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 {
toneType: 'normal',
brightness: 50,
contrast: 50,
shadowRatio: 0.33,
highlightRatio: 0.33,
histogram: defaultHistogram,
}
}
}
@@ -216,12 +172,11 @@ function analyzeTone(
*/
export async function calculateHistogramAndAnalyzeTone(
sharpInstance: sharp.Sharp,
imageLogger?: Logger['image'],
): Promise<ToneAnalysis | null> {
const histogram = await calculateHistogram(sharpInstance, imageLogger)
const histogram = await calculateHistogram(sharpInstance)
if (!histogram) {
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')
export async function loadExistingManifest(): Promise<AfilmoryManifest> {
let manifest: AfilmoryManifest
try {
const manifestContent = await fs.readFile(manifestPath, 'utf-8')
const manifest = JSON.parse(manifestContent) as AfilmoryManifest
if (manifest.version !== 'v2') {
throw new Error('Invalid manifest version')
}
return manifest
manifest = JSON.parse(manifestContent) as AfilmoryManifest
} catch {
logger.fs.error(
'🔍 未找到 manifest 文件/解析失败,创建新的 manifest 文件...',
)
return {
version: 'v2',
version: 'v3',
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,
JSON.stringify(
{
version: 'v2',
version: 'v3',
data: sortedManifest,
} as AfilmoryManifest,
null,

View File

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

View File

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

View File

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