mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "アクション",
|
||||
|
||||
@@ -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 이미지 형식 변환 중...",
|
||||
|
||||
@@ -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 图像格式...",
|
||||
|
||||
@@ -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 圖像格式...",
|
||||
|
||||
@@ -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 圖像格式...",
|
||||
|
||||
@@ -312,7 +312,7 @@ class PhotoGalleryBuilder {
|
||||
): Promise<AfilmoryManifest> {
|
||||
return options.isForceMode || options.isForceManifest
|
||||
? {
|
||||
version: 'v2',
|
||||
version: 'v3',
|
||||
data: [],
|
||||
}
|
||||
: await loadExistingManifest()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PhotoManifestItem } from './photo'
|
||||
|
||||
export type AfilmoryManifest = {
|
||||
version: 'v2'
|
||||
version: 'v3'
|
||||
data: PhotoManifestItem[]
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface ToneAnalysis {
|
||||
contrast: number // 0-100,对比度
|
||||
shadowRatio: number // 0-1,阴影区域占比
|
||||
highlightRatio: number // 0-1,高光区域占比
|
||||
histogram: CompressedHistogramData // 压缩的直方图数据
|
||||
}
|
||||
|
||||
export interface PhotoInfo {
|
||||
|
||||
Reference in New Issue
Block a user