From b14dd85b2842c70cda5d0ad44be16dc5600a45a1 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 13 Jun 2025 22:13:34 +0800 Subject: [PATCH] feat: enhance RSS EXIF specification and update related components - Updated the RSS EXIF specification to version 1.1, adding protocol metadata and expanding EXIF field definitions, including location and technical parameters. - Modified the feed-sitemap generation to include new EXIF fields and improved date handling. - Adjusted the SlidingNumber component to use a predefined spring transition for smoother animations. - Updated VSCode settings to exclude specific directories from search. Signed-off-by: Innei --- .vscode/settings.json | 4 + .../components/ui/number/SlidingNumber.tsx | 7 +- plugins/vite/feed-sitemap.ts | 221 ++++++++++++++++-- rss-spec.md | 206 ++++++++++++++-- 4 files changed, 400 insertions(+), 38 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 64057258..25d78ca8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,10 @@ "source.fixAll.eslint": "explicit" } }, + "search.exclude": { + "**/node_modules": true, + ".specstory": true + }, // If you do not want to autofix some rules on save // You can put this in your user settings or workspace settings "eslint.codeActionsOnSave.rules": [ diff --git a/apps/web/src/components/ui/number/SlidingNumber.tsx b/apps/web/src/components/ui/number/SlidingNumber.tsx index 4cfe5e22..04f491be 100644 --- a/apps/web/src/components/ui/number/SlidingNumber.tsx +++ b/apps/web/src/components/ui/number/SlidingNumber.tsx @@ -6,6 +6,7 @@ import * as React from 'react' import useMeasure from 'react-use-measure' import { clsxm } from '~/lib/cn' +import { Spring } from '~/lib/spring' type SlidingNumberRollerProps = { prevValue: number @@ -109,11 +110,7 @@ function SlidingNumber({ padStart = false, decimalSeparator = '.', decimalPlaces = 0, - transition = { - stiffness: 200, - damping: 20, - mass: 0.4, - }, + transition = Spring.presets.snappy, ...props }: SlidingNumberProps) { const localRef = React.useRef(null) diff --git a/plugins/vite/feed-sitemap.ts b/plugins/vite/feed-sitemap.ts index 56294219..f3764d12 100644 --- a/plugins/vite/feed-sitemap.ts +++ b/plugins/vite/feed-sitemap.ts @@ -116,7 +116,7 @@ ${exifTags} .join('\n') return ` - + <![CDATA[${config.title}]]> ${config.url} @@ -126,6 +126,10 @@ ${exifTags} ${now} 60 Copyright ${config.author.name} + + + 1.1 + afilmory-rss-exif ${ config.feed?.folo?.challenge ? ` @@ -151,19 +155,21 @@ ${rssItems} } function generateExifTags(exif: any, photo: PhotoData): string { - if (!exif || !exif.Photo) { + if (!exif) { return '' } const tags: string[] = [] + // === 基础相机设置参数 (basic) === + // Aperture (光圈) - if (exif.Photo.FNumber) { + if (exif.Photo?.FNumber) { tags.push(` f/${exif.Photo.FNumber}`) } // Shutter Speed (快门) - if (exif.Photo.ExposureTime) { + if (exif.Photo?.ExposureTime) { const shutterSpeed = exif.Photo.ExposureTime >= 1 ? `${exif.Photo.ExposureTime}s` @@ -172,12 +178,12 @@ function generateExifTags(exif: any, photo: PhotoData): string { } // ISO - if (exif.Photo.ISOSpeedRatings) { + if (exif.Photo?.ISOSpeedRatings) { tags.push(` ${exif.Photo.ISOSpeedRatings}`) } // Exposure Compensation (曝光补偿) - if (exif.Photo.ExposureBiasValue !== undefined) { + if (exif.Photo?.ExposureBiasValue !== undefined) { const ev = exif.Photo.ExposureBiasValue const evString = ev > 0 ? `+${ev}` : `${ev}` tags.push( @@ -185,17 +191,32 @@ function generateExifTags(exif: any, photo: PhotoData): string { ) } + // === 图像属性 (basic) === + // Image Dimensions (图片宽度, 高度) tags.push( ` ${photo.width}`, ` ${photo.height}`, ) - // Date Taken (拍摄时间) - if (exif.Photo.DateTimeOriginal) { - tags.push( - ` ${exif.Photo.DateTimeOriginal}`, - ) + // Date Taken (拍摄时间) - 转换为ISO 8601格式 + if (exif.Photo?.DateTimeOriginal) { + try { + // 尝试解析EXIF日期格式 (YYYY:MM:DD HH:mm:ss) + const exifDate = exif.Photo.DateTimeOriginal.replaceAll(':', '-').replace( + /-(\d{2}:\d{2}:\d{2})/, + ' $1', + ) + const isoDate = new Date(exifDate).toISOString() + tags.push(` ${isoDate}`) + } catch { + // 如果解析失败,使用photo.dateTaken + const isoDate = new Date(photo.dateTaken).toISOString() + tags.push(` ${isoDate}`) + } + } else { + const isoDate = new Date(photo.dateTaken).toISOString() + tags.push(` ${isoDate}`) } // Camera Model (机型) @@ -205,30 +226,202 @@ function generateExifTags(exif: any, photo: PhotoData): string { ) } + // Orientation (图像方向) + if (exif.Image?.Orientation) { + tags.push( + ` ${exif.Image.Orientation}`, + ) + } + + // === 镜头参数 (lens) === + // Lens Model (镜头) - if (exif.Photo.LensModel) { + if (exif.Photo?.LensModel) { tags.push( ` `, ) } // Focal Length (焦段) - if (exif.Photo.FocalLength) { + if (exif.Photo?.FocalLength) { tags.push( ` ${exif.Photo.FocalLength}mm`, ) } // Focal Length in 35mm equivalent (等效35mm焦距) - if (exif.Photo.FocalLengthIn35mmFilm) { + if (exif.Photo?.FocalLengthIn35mmFilm) { tags.push( ` ${exif.Photo.FocalLengthIn35mmFilm}mm`, ) } + // Max Aperture (镜头最大光圈) + if (exif.Photo?.MaxApertureValue) { + const maxAperture = Math.pow(2, exif.Photo.MaxApertureValue / 2) + tags.push( + ` f/${maxAperture.toFixed(1)}`, + ) + } + + // === 位置信息 (location) === + + // GPS Coordinates + if (exif.GPS?.GPSLatitude && exif.GPS?.GPSLongitude) { + const lat = convertDMSToDD(exif.GPS.GPSLatitude, exif.GPS.GPSLatitudeRef) + const lng = convertDMSToDD(exif.GPS.GPSLongitude, exif.GPS.GPSLongitudeRef) + if (lat !== null && lng !== null) { + tags.push( + ` ${lat}`, + ` ${lng}`, + ) + } + } + + // Altitude (海拔) + if (exif.GPS?.GPSAltitude) { + const altitude = + exif.GPS.GPSAltitudeRef === 1 + ? -exif.GPS.GPSAltitude + : exif.GPS.GPSAltitude + tags.push(` ${altitude}m`) + } + + // === 技术参数 (technical) === + + // White Balance (白平衡) + if (exif.Photo?.WhiteBalance !== undefined) { + const whiteBalanceMap = { 0: 'Auto', 1: 'Manual' } + const wb = + whiteBalanceMap[ + exif.Photo.WhiteBalance as keyof typeof whiteBalanceMap + ] || 'Auto' + tags.push(` ${wb}`) + } + + // Metering Mode (测光模式) + if (exif.Photo?.MeteringMode !== undefined) { + const meteringModeMap = { + 0: 'Unknown', + 1: 'Average', + 2: 'Center-weighted', + 3: 'Spot', + 4: 'Multi-spot', + 5: 'Pattern', + 6: 'Partial', + } + const mode = + meteringModeMap[exif.Photo.MeteringMode as keyof typeof meteringModeMap] + if (mode && mode !== 'Unknown') { + tags.push(` ${mode}`) + } + } + + // Flash Mode (闪光灯模式) + if (exif.Photo?.Flash !== undefined) { + const flashFired = (exif.Photo.Flash & 0x01) !== 0 + const flashMode = flashFired ? 'On' : 'Off' + tags.push(` ${flashMode}`) + } + + // Color Space (色彩空间) + if (exif.Photo?.ColorSpace !== undefined) { + const colorSpaceMap = { 1: 'sRGB', 65535: 'Uncalibrated' } + const colorSpace = + colorSpaceMap[exif.Photo.ColorSpace as keyof typeof colorSpaceMap] || + 'sRGB' + tags.push(` ${colorSpace}`) + } + + // === 高级参数 (advanced) === + + // Exposure Program (曝光程序) + if (exif.Photo?.ExposureProgram !== undefined) { + const exposureProgramMap = { + 0: 'Not defined', + 1: 'Manual', + 2: 'Program', + 3: 'Aperture Priority', + 4: 'Shutter Priority', + 5: 'Creative', + 6: 'Action', + 7: 'Portrait', + 8: 'Landscape', + } + const program = + exposureProgramMap[ + exif.Photo.ExposureProgram as keyof typeof exposureProgramMap + ] + if (program && program !== 'Not defined') { + tags.push(` ${program}`) + } + } + + // Scene Mode (场景模式) + if (exif.Photo?.SceneCaptureType !== undefined) { + const sceneModeMap = { + 0: 'Standard', + 1: 'Landscape', + 2: 'Portrait', + 3: 'Night', + } + const scene = + sceneModeMap[exif.Photo.SceneCaptureType as keyof typeof sceneModeMap] + if (scene) { + tags.push(` `) + } + } + + // Contrast (对比度) + if (exif.Photo?.Contrast !== undefined) { + const contrastMap = { 0: 'Normal', 1: 'Low', 2: 'High' } + const contrast = + contrastMap[exif.Photo.Contrast as keyof typeof contrastMap] + if (contrast) { + tags.push(` ${contrast}`) + } + } + + // Saturation (饱和度) + if (exif.Photo?.Saturation !== undefined) { + const saturationMap = { 0: 'Normal', 1: 'Low', 2: 'High' } + const saturation = + saturationMap[exif.Photo.Saturation as keyof typeof saturationMap] + if (saturation) { + tags.push(` ${saturation}`) + } + } + + // Sharpness (锐度) + if (exif.Photo?.Sharpness !== undefined) { + const sharpnessMap = { 0: 'Normal', 1: 'Soft', 2: 'Hard' } + const sharpness = + sharpnessMap[exif.Photo.Sharpness as keyof typeof sharpnessMap] + if (sharpness) { + tags.push(` ${sharpness}`) + } + } + return tags.join('\n') } +// Helper function to convert DMS (Degrees, Minutes, Seconds) to DD (Decimal Degrees) +function convertDMSToDD(dms: number[], ref: string): number | null { + if (!dms || dms.length !== 3) return null + + const degrees = dms[0] + const minutes = dms[1] + const seconds = dms[2] + + let dd = degrees + minutes / 60 + seconds / 3600 + + if (ref === 'S' || ref === 'W') { + dd = dd * -1 + } + + return Math.round(dd * 1000000) / 1000000 // 保留6位小数 +} + function generateSitemap(photos: PhotoData[], config: SiteConfig): string { const now = new Date().toISOString() diff --git a/rss-spec.md b/rss-spec.md index 9d251069..c9d8cc77 100644 --- a/rss-spec.md +++ b/rss-spec.md @@ -2,24 +2,34 @@ ## 概述 -本规范定义了在 RSS 2.0 feeds 中包含摄影 EXIF 数据的标准方法,专为照片画廊网站设计。该扩展允许 RSS 阅读器和其他应用程序访问详细的摄影技术参数。 +本规范定义了在 RSS 2.0 feeds 中包含摄影 EXIF 数据的标准方法,专为照片画廊网站设计。该扩展允许 RSS 阅读器和其他应用程序访问详细的摄影技术参数,并支持不同类型的图片展示。 -## 命名空间 +## 协议版本 -XML 命名空间: `https://exif.org/rss/1.0` -推荐前缀: `exif` +**当前版本**: `1.1` +**协议标识**: `afilmory-rss-exif` -在 RSS 根元素中声明命名空间: +## 协议元数据 -```xml - -``` +### `` + +**描述**: 协议版本号 +**位置**: `` 元素内 +**格式**: 语义化版本号 +**示例**: `1.1` +**必需**: 是 + +### `` + +**描述**: 协议标识符 +**位置**: `` 元素内 +**格式**: 字符串标识符 +**示例**: `afilmory-rss-exif` +**必需**: 是 ## EXIF 标签定义 -### 相机设置参数 +### 相机设置参数 (basic) #### `` @@ -57,7 +67,7 @@ XML 命名空间: `https://exif.org/rss/1.0` - `-1.3 EV` **映射**: EXIF ExposureBiasValue 字段 -### 镜头参数 +### 镜头参数 (lens) #### `` @@ -80,7 +90,14 @@ XML 命名空间: `https://exif.org/rss/1.0` **示例**: `` **映射**: EXIF LensModel 字段 -### 设备信息 +#### `` + +**描述**: 镜头最大光圈 +**格式**: `f/{数值}` +**示例**: `f/1.4` +**映射**: EXIF MaxApertureValue 字段 + +### 设备信息 (basic) #### `` @@ -89,7 +106,7 @@ XML 命名空间: `https://exif.org/rss/1.0` **示例**: `` **映射**: EXIF Make + Model 字段组合 -### 图像属性 +### 图像属性 (basic) #### `` @@ -112,15 +129,132 @@ XML 命名空间: `https://exif.org/rss/1.0` **示例**: `2025-06-05T16:12:43.000Z` **映射**: EXIF DateTimeOriginal 字段 +#### `` + +**描述**: 图像方向 +**格式**: 整数 (1-8) +**示例**: `1` +**映射**: EXIF Orientation 字段 + +### 位置信息 (location) + +#### `` + +**描述**: GPS纬度 +**格式**: 十进制度数 +**示例**: `39.9042` +**映射**: EXIF GPSLatitude 字段 + +#### `` + +**描述**: GPS经度 +**格式**: 十进制度数 +**示例**: `116.4074` +**映射**: EXIF GPSLongitude 字段 + +#### `` + +**描述**: 海拔高度 +**格式**: `{数值}m` +**示例**: `1200m` +**映射**: EXIF GPSAltitude 字段 + +#### `` + +**描述**: 拍摄地点名称 +**格式**: CDATA 包装的字符串 +**示例**: `` +**映射**: 地理编码或用户标注 + +### 技术参数 (technical) + +#### `` + +**描述**: 白平衡设置 +**格式**: 枚举值 +**示例**: `Auto` +**映射**: EXIF WhiteBalance 字段 +**可选值**: `Auto`, `Daylight`, `Cloudy`, `Tungsten`, `Fluorescent`, `Flash`, `Manual` + +#### `` + +**描述**: 测光模式 +**格式**: 枚举值 +**示例**: `Matrix` +**映射**: EXIF MeteringMode 字段 +**可选值**: `Matrix`, `Center-weighted`, `Spot`, `Multi-spot`, `Pattern` + +#### `` + +**描述**: 闪光灯模式 +**格式**: 枚举值 +**示例**: `Off` +**映射**: EXIF Flash 字段 +**可选值**: `Off`, `On`, `Auto`, `Red-eye`, `Fill`, `Slow-sync` + +#### `` + +**描述**: 色彩空间 +**格式**: 枚举值 +**示例**: `sRGB` +**映射**: EXIF ColorSpace 字段 +**可选值**: `sRGB`, `Adobe RGB`, `ProPhoto RGB`, `DCI-P3` + +### 高级参数 (advanced) + +#### `` + +**描述**: 曝光程序 +**格式**: 枚举值 +**示例**: `Aperture Priority` +**映射**: EXIF ExposureProgram 字段 +**可选值**: `Manual`, `Program`, `Aperture Priority`, `Shutter Priority`, `Creative`, `Action`, `Portrait`, `Landscape` + +#### `` + +**描述**: 场景模式 +**格式**: CDATA 包装的字符串 +**示例**: `` +**映射**: EXIF SceneCaptureType 字段 + +#### `` + +**描述**: 对比度设置 +**格式**: 枚举值 +**示例**: `Normal` +**映射**: EXIF Contrast 字段 +**可选值**: `Low`, `Normal`, `High` + +#### `` + +**描述**: 饱和度设置 +**格式**: 枚举值 +**示例**: `Normal` +**映射**: EXIF Saturation 字段 +**可选值**: `Low`, `Normal`, `High` + +#### `` + +**描述**: 锐度设置 +**格式**: 枚举值 +**示例**: `Normal` +**映射**: EXIF Sharpness 字段 +**可选值**: `Soft`, `Normal`, `Hard` + ## 完整示例 ```xml - + - <![CDATA[我的摄影画廊]]> + <![CDATA[我的风景摄影画廊]]> https://example.com - + + + + 1.1 + afilmory-rss-exif + <![CDATA[夕阳下的城市]]> @@ -132,7 +266,7 @@ XML 命名空间: `https://exif.org/rss/1.0` - + f/1.4 1/250s 1000 @@ -141,9 +275,25 @@ XML 命名空间: `https://exif.org/rss/1.0` 5152 2025-06-05T16:12:43.000Z + 1 + + 50mm 75mm + f/1.4 + + + 39.9042 + 116.4074 + 50m + + + + Auto + Matrix + Off + sRGB @@ -158,21 +308,39 @@ XML 命名空间: `https://exif.org/rss/1.0` ### 可选字段 -- 所有 EXIF 标签都是可选的 +- 除协议版本和图片展类型外,所有 EXIF 标签都是可选的 - 如果某个 EXIF 数据不可用,应省略对应的标签而不是输出空值 +- 实现者可以根据 `supportedFields` 声明选择性实现字段集 ### 数据验证 - 实现者应验证 EXIF 数据的有效性 - 对于无效或缺失的数据,建议静默跳过而不是输出错误值 +- 枚举类型字段应严格验证取值范围 ### 性能考虑 - EXIF 数据提取可能影响 RSS 生成性能 - 建议在构建时预处理 EXIF 数据而非实时提取 +- 可根据 `supportedFields` 声明优化数据提取范围 + +### 字段集实现建议 + +- `basic` 字段集适用于大多数基础应用 +- `location` 字段集需要考虑隐私保护 +- `advanced` 字段集适用于专业摄影应用 +- 实现者可以自定义字段集组合 ## 版本历史 +- **v1.1** (2025-01-19): 扩展规范 + + - 添加协议版本号和标识符 + - 定义图片展类型系统 + - 引入EXIF字段集概念 + - 扩充EXIF字段定义 + - 添加位置信息、技术参数和高级参数字段集 + - **v1.0** (2025-01-19): 初始规范发布 - 定义核心 EXIF 标签集合 - 建立命名空间约定