11import type { PhotoManifestItem } from '@afilmory/builder'
22
33const GENERATOR_NAME = 'Afilmory Feed Generator'
4+ const EXIF_NAMESPACE = 'https://afilmory.com/rss/exif'
5+ const PROTOCOL_VERSION = '1.1'
6+ const PROTOCOL_ID = 'afilmory-rss-exif'
47
58export interface FeedSiteAuthor {
69 name : string
@@ -29,7 +32,7 @@ export function generateRSSFeed(photos: readonly PhotoManifestItem[], config: Fe
2932 const managingEditor = author && config . author ?. url ? `${ author } (${ config . author . url } )` : author
3033
3134 return `<?xml version="1.0" encoding="UTF-8"?>
32- <rss version="2.0">
35+ <rss version="2.0" xmlns:exif=" ${ EXIF_NAMESPACE } " >
3336 <channel>
3437 <title>${ escapeXml ( config . title ) } </title>
3538 <link>${ baseUrl } </link>
@@ -38,6 +41,8 @@ export function generateRSSFeed(photos: readonly PhotoManifestItem[], config: Fe
3841 <lastBuildDate>${ lastBuildDate } </lastBuildDate>
3942 <generator>${ GENERATOR_NAME } </generator>
4043 ${ managingEditor ? `<managingEditor>${ managingEditor } </managingEditor>` : '' }
44+ <exif:version>${ PROTOCOL_VERSION } </exif:version>
45+ <exif:protocol>${ PROTOCOL_ID } </exif:protocol>
4146${ itemsXml }
4247 </channel>
4348</rss>`
@@ -53,16 +58,152 @@ function createItemXml(photo: PhotoManifestItem, baseUrl: string): string {
5358 ? photo . tags . map ( ( tag ) => ` <category>${ escapeXml ( tag ) } </category>` ) . join ( '\n' )
5459 : ''
5560
61+ // Add enclosure for thumbnail if available
62+ // Assuming thumbnail is an image, default to image/jpeg if extension is unknown, but usually it's webp or jpg
63+ let enclosure = ''
64+ if ( photo . thumbnailUrl ) {
65+ const thumbUrl = photo . thumbnailUrl . startsWith ( 'http' )
66+ ? photo . thumbnailUrl
67+ : `${ baseUrl } ${ photo . thumbnailUrl . startsWith ( '/' ) ? '' : '/' } ${ photo . thumbnailUrl } `
68+ // Simple mime type guess
69+ const mimeType = thumbUrl . endsWith ( '.webp' ) ? 'image/webp' : thumbUrl . endsWith ( '.png' ) ? 'image/png' : 'image/jpeg'
70+ enclosure = ` <enclosure url="${ escapeXml ( thumbUrl ) } " type="${ mimeType } " length="0" />`
71+ }
72+
73+ const exifTags = buildExifTags ( photo )
74+
5675 return ` <item>
5776 <title>${ title } </title>
5877 <link>${ link } </link>
5978 <guid isPermaLink="false">${ escapeXml ( photo . id ) } </guid>
6079 <pubDate>${ pubDate } </pubDate>
6180 <description><![CDATA[${ summary } ]]></description>
6281${ categories }
82+ ${ enclosure }
83+ ${ exifTags }
6384 </item>`
6485}
6586
87+ function buildExifTags ( photo : PhotoManifestItem ) : string {
88+ if ( ! photo . exif ) return ''
89+
90+ const tags : string [ ] = [ ]
91+ const { exif } = photo
92+
93+ // --- Basic Camera Settings ---
94+ if ( exif . FNumber ) {
95+ tags . push ( `<exif:aperture>f/${ exif . FNumber } </exif:aperture>` )
96+ }
97+ if ( exif . ExposureTime ) {
98+ // Format shutter speed: if < 1, use fraction, else use seconds
99+ let ss = String ( exif . ExposureTime )
100+ if ( typeof exif . ExposureTime === 'number' ) {
101+ if ( exif . ExposureTime < 1 && exif . ExposureTime > 0 ) {
102+ ss = `1/${ Math . round ( 1 / exif . ExposureTime ) } s`
103+ } else {
104+ ss = `${ exif . ExposureTime } s`
105+ }
106+ } else if ( ! ss . endsWith ( 's' ) && // If it's a string and doesn't end with s, append it?
107+ // Actually exiftool usually gives nice strings or numbers.
108+ // Let's just trust the value but ensure 's' suffix if it looks like a number
109+ ! Number . isNaN ( Number ( ss ) ) ) {
110+ ss = `${ ss } s`
111+ }
112+ tags . push ( `<exif:shutterSpeed>${ ss } </exif:shutterSpeed>` )
113+ }
114+ if ( exif . ISO ) {
115+ tags . push ( `<exif:iso>${ exif . ISO } </exif:iso>` )
116+ }
117+ if ( exif . ExposureCompensation !== undefined && exif . ExposureCompensation !== null ) {
118+ const val = Number ( exif . ExposureCompensation )
119+ const sign = val > 0 ? '+' : ''
120+ tags . push ( `<exif:exposureCompensation>${ sign } ${ val } EV</exif:exposureCompensation>` )
121+ }
122+
123+ // --- Lens Parameters ---
124+ if ( exif . FocalLength ) {
125+ // Ensure 'mm' suffix
126+ const fl = String ( exif . FocalLength ) . replace ( 'mm' , '' ) . trim ( )
127+ tags . push ( `<exif:focalLength>${ fl } mm</exif:focalLength>` )
128+ }
129+ if ( exif . FocalLengthIn35mmFormat ) {
130+ const fl35 = String ( exif . FocalLengthIn35mmFormat ) . replace ( 'mm' , '' ) . trim ( )
131+ tags . push ( `<exif:focalLength35mm>${ fl35 } mm</exif:focalLength35mm>` )
132+ }
133+ if ( exif . LensModel ) {
134+ tags . push ( `<exif:lens><![CDATA[${ exif . LensModel } ]]></exif:lens>` )
135+ }
136+ if ( exif . MaxApertureValue ) {
137+ tags . push ( `<exif:maxAperture>f/${ exif . MaxApertureValue } </exif:maxAperture>` )
138+ }
139+
140+ // --- Device Info ---
141+ const camera = [ exif . Make , exif . Model ] . filter ( Boolean ) . join ( ' ' )
142+ if ( camera ) {
143+ tags . push ( `<exif:camera><![CDATA[${ camera } ]]></exif:camera>` )
144+ }
145+
146+ // --- Image Attributes ---
147+ if ( photo . width ) {
148+ tags . push ( `<exif:imageWidth>${ photo . width } </exif:imageWidth>` )
149+ }
150+ if ( photo . height ) {
151+ tags . push ( `<exif:imageHeight>${ photo . height } </exif:imageHeight>` )
152+ }
153+ if ( photo . dateTaken ) {
154+ tags . push ( `<exif:dateTaken>${ photo . dateTaken } </exif:dateTaken>` )
155+ }
156+ if ( exif . Orientation ) {
157+ tags . push ( `<exif:orientation>${ exif . Orientation } </exif:orientation>` )
158+ }
159+
160+ // --- Location Info ---
161+ // Location info removed as per user request
162+
163+ // Location name is not directly in standard exif usually, but if we had it in photo info...
164+ // Currently PhotoManifestItem doesn't seem to have a dedicated location name field other than maybe tags or description.
165+ // We'll skip <exif:location> for now unless we find a source.
166+
167+ // --- Technical Parameters ---
168+ if ( exif . WhiteBalance ) {
169+ tags . push ( `<exif:whiteBalance>${ exif . WhiteBalance } </exif:whiteBalance>` )
170+ }
171+ if ( exif . MeteringMode ) {
172+ tags . push ( `<exif:meteringMode>${ exif . MeteringMode } </exif:meteringMode>` )
173+ }
174+ // Flash is often a complex object or string in exiftool, simplify if possible or just dump string
175+ if ( exif . Flash ) {
176+ // Try to map to simple enum if possible, or just use what we have if it's readable
177+ tags . push ( `<exif:flashMode>${ String ( exif . Flash ) } </exif:flashMode>` )
178+ }
179+ if ( exif . ColorSpace ) {
180+ tags . push ( `<exif:colorSpace>${ exif . ColorSpace } </exif:colorSpace>` )
181+ }
182+
183+ // --- Advanced Parameters ---
184+ if ( exif . ExposureProgram ) {
185+ tags . push ( `<exif:exposureProgram>${ exif . ExposureProgram } </exif:exposureProgram>` )
186+ }
187+ if ( exif . SceneCaptureType ) {
188+ tags . push ( `<exif:sceneMode><![CDATA[${ exif . SceneCaptureType } ]]></exif:sceneMode>` )
189+ }
190+
191+ // Try to extract from FujiRecipe if available
192+ if ( exif . FujiRecipe ) {
193+ if ( exif . FujiRecipe . Sharpness ) {
194+ tags . push ( `<exif:sharpness>${ exif . FujiRecipe . Sharpness } </exif:sharpness>` )
195+ }
196+ if ( exif . FujiRecipe . Saturation ) {
197+ tags . push ( `<exif:saturation>${ exif . FujiRecipe . Saturation } </exif:saturation>` )
198+ }
199+ // Contrast is often "HighlightTone" and "ShadowTone" combined in Fuji,
200+ // or maybe just map one of them? The spec asks for Contrast.
201+ // Let's skip Contrast for FujiRecipe to avoid confusion unless we have a direct mapping.
202+ }
203+
204+ return tags . map ( ( t ) => ` ${ t } ` ) . join ( '\n' )
205+ }
206+
66207function buildDescription ( photo : PhotoManifestItem ) : string {
67208 const segments : string [ ] = [ ]
68209 if ( photo . description ) {
0 commit comments