grain.social is a photo sharing platform built on atproto.
grain.social
atproto
photography
appview
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}