grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
50
fork

Configure Feed

Select the types of activity you want to include in your feed.

at a456d5309ca6cd8b11f00b53d8fc01e1ad8d9052 352 lines 9.4 kB view raw
1import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 2import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 3import { Record as GalleryItem } from "$lexicon/types/social/grain/gallery/item.ts"; 4import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 5import { ExifView, PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 6import { Record as PhotoExif } from "$lexicon/types/social/grain/photo/exif.ts"; 7import { $Typed } from "$lexicon/util.ts"; 8import { AtUri } from "@atproto/syntax"; 9import { BffContext, WithBffMeta } from "@bigmoves/bff"; 10import { format, parseISO } from "date-fns"; 11import { PUBLIC_URL, USE_CDN } from "../env.ts"; 12import { getActorProfile } from "./actor.ts"; 13import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts"; 14 15export function getPhoto( 16 uri: string, 17 ctx: BffContext, 18): $Typed<PhotoView> | null { 19 const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(uri); 20 if (!photo) return null; 21 const { items: exifItems } = ctx.indexService.getRecords< 22 WithBffMeta<PhotoExif> 23 >( 24 "social.grain.photo.exif", 25 { 26 where: [{ field: "photo", equals: uri }], 27 }, 28 ); 29 return photoToView( 30 photo.did, 31 photo, 32 exifItems.length > 0 ? exifItems[0] : undefined, 33 ); 34} 35 36export function photoThumb(did: string, cid: string) { 37 return photoUrl(did, cid, "thumbnail"); 38} 39 40export function photoToView( 41 did: string, 42 photo: WithBffMeta<Photo>, 43 exif?: WithBffMeta<PhotoExif>, 44 item?: WithBffMeta<GalleryItem>, 45): $Typed<PhotoView> { 46 return { 47 $type: "social.grain.photo.defs#photoView", 48 uri: photo.uri, 49 cid: photo.photo.ref.toString(), 50 thumb: photoUrl(did, photo.photo.ref.toString(), "thumbnail"), 51 fullsize: photoUrl(did, photo.photo.ref.toString(), "fullsize"), 52 alt: photo.alt, 53 aspectRatio: photo.aspectRatio, 54 exif: exif ? exifToView(exif) : undefined, 55 gallery: item 56 ? { 57 item: item.uri, 58 itemPosition: item.position, 59 itemCreatedAt: item.createdAt, 60 } 61 : undefined, 62 }; 63} 64 65export function photoUrl( 66 did: string, 67 cid: string, 68 type: "thumbnail" | "fullsize" = "fullsize", 69): string { 70 if (!USE_CDN) { 71 return `${PUBLIC_URL}/actions/get-blob?did=${did}&cid=${cid}`; 72 } 73 return `https://cdn.bsky.app/img/feed_${type}/plain/${did}/${cid}@jpeg`; 74} 75 76export function exifToView( 77 exif: WithBffMeta<PhotoExif>, 78): $Typed<ExifView> { 79 const deserializedExif = deserializeExif(exif); 80 return { 81 ...deserializedExif, 82 make: deserializedExif.make ? formatMake(deserializedExif.make) : undefined, 83 fNumber: deserializedExif.fNumber 84 ? formatAperture(deserializedExif.fNumber) 85 : undefined, 86 dateTimeOriginal: deserializedExif.dateTimeOriginal 87 ? format( 88 parseISO(deserializedExif.dateTimeOriginal), 89 "MMM d, yyyy, h:mm a", 90 ) 91 : undefined, 92 focalLengthIn35mmFormat: deserializedExif.focalLengthIn35mmFormat 93 ? `${deserializedExif.focalLengthIn35mmFormat}mm` 94 : undefined, 95 exposureTime: deserializedExif.exposureTime !== undefined 96 ? formatExposureTime(deserializedExif.exposureTime) 97 : undefined, 98 $type: "social.grain.photo.defs#exifView", 99 }; 100} 101 102function formatMake(make: string) { 103 return make 104 .toLowerCase() 105 .split(" ") 106 .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 107 .join(" "); 108} 109 110function formatAperture(fNumber: number): string { 111 return `ƒ/${Number.isInteger(fNumber) ? fNumber : fNumber.toFixed(1)}`; 112} 113 114function formatExposureTime(seconds: number): string { 115 if (seconds >= 1) { 116 return `${seconds}s`; 117 } 118 119 const denominator = Math.round(1 / seconds); 120 return `1/${denominator}`; 121} 122 123const SCALE_FACTOR = 1000000; 124 125export function deserializeExif( 126 exif: WithBffMeta<PhotoExif>, 127 scale: number = SCALE_FACTOR, 128): WithBffMeta<PhotoExif> { 129 const deserialized: Partial<WithBffMeta<PhotoExif>> = { 130 $type: exif.$type, 131 photo: exif.photo, 132 createdAt: exif.createdAt, 133 }; 134 135 for (const [key, value] of Object.entries(exif)) { 136 if (typeof value === "number") { 137 deserialized[key] = value / scale; 138 } else if (Array.isArray(value)) { 139 deserialized[key] = value.map((v) => 140 typeof v === "number" ? v / scale : v 141 ); 142 } else { 143 deserialized[key] = value; 144 } 145 } 146 147 deserialized.indexedAt = exif.indexedAt; 148 deserialized.cid = exif.cid; 149 deserialized.did = exif.did; 150 deserialized.uri = exif.uri; 151 152 return deserialized as WithBffMeta<PhotoExif>; 153} 154 155const exifDisplayNames: Record<string, string> = { 156 Make: "Make", 157 Model: "Model", 158 LensMake: "Lens Make", 159 LensModel: "Lens Model", 160 FNumber: "Aperture", 161 FocalLengthIn35mmFormat: "Focal Length", 162 ExposureTime: "Exposure Time", 163 ISO: "ISO", 164 Flash: "Flash", 165 DateTimeOriginal: "Date Taken", 166}; 167 168const tagOrder = [ 169 "Make", 170 "Model", 171 "LensMake", 172 "LensModel", 173 "FNumber", 174 "FocalLengthIn35mmFormat", 175 "ExposureTime", 176 "ISO", 177 "Flash", 178 "DateTimeOriginal", 179]; 180 181export function getOrderedExifData(photo: PhotoView) { 182 if (!photo.exif) return []; 183 const entries = Object.entries(photo.exif) 184 .filter(([key]) => 185 tagOrder.some((tag) => tag.toLowerCase() === key.toLowerCase()) 186 ) 187 .map(([key, value]) => { 188 const tagKey = tagOrder.find( 189 (tag) => tag.toLowerCase() === key.toLowerCase(), 190 ); 191 const displayKey = tagKey && exifDisplayNames[tagKey] 192 ? exifDisplayNames[tagKey] 193 : key; 194 return { key, displayKey, value }; 195 }); 196 197 // Sort according to tagOrder, unknown tags go last in original order 198 return entries.sort((a, b) => { 199 const aIdx = tagOrder.findIndex( 200 (tag) => tag.toLowerCase() === a.key.toLowerCase(), 201 ); 202 const bIdx = tagOrder.findIndex( 203 (tag) => tag.toLowerCase() === b.key.toLowerCase(), 204 ); 205 if (aIdx === -1 && bIdx === -1) return 0; 206 if (aIdx === -1) return 1; 207 if (bIdx === -1) return -1; 208 return aIdx - bIdx; 209 }); 210} 211 212export function getPhotoGalleries( 213 photoUri: string, 214 ctx: BffContext, 215): GalleryView[] { 216 const { items: galleryItems } = ctx.indexService.getRecords< 217 WithBffMeta<GalleryItem> 218 >( 219 "social.grain.gallery.item", 220 { 221 where: [{ field: "item", equals: photoUri }], 222 }, 223 ); 224 225 const galleryUris = Array.from( 226 new Set(galleryItems.map((item) => item.gallery)), 227 ); 228 if (galleryUris.length === 0) return []; 229 230 const { items: galleries } = ctx.indexService.getRecords< 231 WithBffMeta<Gallery> 232 >( 233 "social.grain.gallery", 234 { 235 where: [{ field: "uri", in: galleryUris }], 236 }, 237 ); 238 if (!galleries.length) return []; 239 240 const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 241 const labels = ctx.indexService.queryLabels({ subjects: galleryUris }); 242 return galleries 243 .map((gallery) => { 244 const profile = getActorProfile(gallery.did, ctx); 245 if (!profile) return undefined; 246 return galleryToView({ 247 record: gallery, 248 creator: profile, 249 items: galleryPhotosMap.get(gallery.uri) ?? [], 250 labels: labels ?? [], 251 }); 252 }) 253 .filter((g): g is $Typed<GalleryView> => Boolean(g)); 254} 255 256export function getPhotosBulk( 257 uris: string[], 258 ctx: BffContext, 259) { 260 if (!uris.length) return []; 261 const { items: photos } = ctx.indexService.getRecords<WithBffMeta<Photo>>( 262 "social.grain.photo", 263 { 264 where: [{ field: "uri", in: uris }], 265 }, 266 ); 267 if (!photos.length) return []; 268 const { items: exifItems } = ctx.indexService.getRecords< 269 WithBffMeta<PhotoExif> 270 >( 271 "social.grain.photo.exif", 272 { 273 where: [{ field: "photo", in: uris }], 274 }, 275 ); 276 const exifMap = new Map<string, WithBffMeta<PhotoExif>>(); 277 for (const exif of exifItems) { 278 exifMap.set(exif.photo, exif); 279 } 280 return photos.map((photo) => 281 photoToView(photo.did, photo, exifMap.get(photo.uri)) 282 ); 283} 284 285export async function createPhoto( 286 data: Partial<Photo>, 287 ctx: BffContext, 288): Promise<string> { 289 const photoUri = await ctx.createRecord<Photo>( 290 "social.grain.photo", 291 { 292 photo: data.photo, 293 alt: data.alt || "", 294 aspectRatio: data.aspectRatio || undefined, 295 createdAt: new Date().toISOString(), 296 }, 297 ); 298 return photoUri; 299} 300 301export async function createExif( 302 data: Partial<PhotoExif>, 303 ctx: BffContext, 304): Promise<string> { 305 const exifUri = await ctx.createRecord<PhotoExif>( 306 "social.grain.photo.exif", 307 { 308 ...data, 309 createdAt: new Date().toISOString(), 310 }, 311 ); 312 return exifUri; 313} 314 315export async function applyAlts( 316 writes: Array<{ 317 photoUri: string; 318 alt: string; 319 }>, 320 ctx: BffContext, 321): Promise<boolean> { 322 const altMap = new Map<string, string>(); 323 for (const update of writes) { 324 altMap.set(update.photoUri, update.alt); 325 } 326 327 const urisToUpdate = writes.map((update) => update.photoUri); 328 329 const { items: photoRecords } = ctx.indexService.getRecords< 330 WithBffMeta<Photo> 331 >( 332 "social.grain.photo", 333 { 334 where: [{ field: "uri", in: urisToUpdate }], 335 }, 336 ); 337 338 const updates = photoRecords.map((record) => ({ 339 collection: "social.grain.photo", 340 rkey: new AtUri(record.uri).rkey, 341 data: { ...record, alt: altMap.get(record.uri) ?? record.alt ?? undefined }, 342 })); 343 344 try { 345 await ctx.updateRecords(updates); 346 } catch (error) { 347 console.error("Failed to update photo alts:", error); 348 return false; 349 } 350 351 return true; 352}