Import Instagram archive to a Bluesky account
9
fork

Configure Feed

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

Reorganized the code base to create more clear separation between files and their purpose. Added markdown files with brief descriptions of each functionality.

+627 -415
+7
src/README.md
··· 1 + # Main 2 + 3 + `main.ts` is the entry point that runs the async application code located in `instagram-to-bluesky.ts`. 4 + 5 + ## Instagram to Bluesky 6 + 7 + `instagram-to-bluesky.ts` is responsible for configuration and delegating to the [media](./media/media.ts) processor which uses media specific processors ([image](./image/image.ts)/[video](./video/video.ts)) that transform the raw instagram post data into a format that can be sent to the [bluesky client](./bluesky/bluesky.ts).
+4 -4
src/app.ts src/instagram-to-bluesky.ts
··· 3 3 import path from 'path'; 4 4 import * as process from 'process'; 5 5 6 - import { BlueskyClient } from './bluesky'; 7 - import { logger } from './logger'; 8 - import { processPost } from './media'; 9 - import { createVideoEmbed, prepareVideoUpload } from './video'; 6 + import { BlueskyClient } from './bluesky/bluesky'; 7 + import { logger } from './logger/logger'; 8 + import { processPost } from './media/media'; 9 + import { createVideoEmbed, prepareVideoUpload } from './video/video'; 10 10 11 11 dotenv.config(); 12 12
-247
src/bluesky.ts
··· 1 - import { AtpAgent, RichText, BlobRef } from "@atproto/api"; 2 - import { logger } from "./logger"; 3 - 4 - export interface VideoEmbed { 5 - $type: "app.bsky.embed.video"; 6 - alt: string; 7 - buffer: Buffer; 8 - mimeType: string; 9 - size?: number; 10 - video?: { 11 - ref: BlobRef; 12 - mimeType: string; 13 - size: number; 14 - }; 15 - } 16 - 17 - export interface VideoEmbedPost { 18 - $type: "app.bsky.embed.video"; 19 - video: BlobRef; 20 - mimeType: string; 21 - size: number; 22 - } 23 - 24 - export interface ImageEmbed { 25 - $type: "app.bsky.embed.images#image"; 26 - alt: string; 27 - image: Buffer | BlobRef; 28 - mimeType: string; 29 - } 30 - 31 - export class ImageEmbedImpl implements ImageEmbed { 32 - readonly $type = "app.bsky.embed.images#image"; 33 - 34 - constructor( 35 - public alt: string, 36 - public image: Buffer | BlobRef, 37 - public mimeType: string 38 - ) {} 39 - 40 - toJSON() { 41 - return { 42 - $type: this.$type, 43 - alt: this.alt, 44 - image: 45 - this.image instanceof Buffer 46 - ? "[Buffer length=" + this.image.length + "]" 47 - : this.image, 48 - }; 49 - } 50 - } 51 - 52 - export interface ImagesEmbed { 53 - $type: "app.bsky.embed.images"; 54 - images: ImageEmbed[]; 55 - } 56 - 57 - type EmbeddedMedia = VideoEmbed | ImageEmbed[] | ImagesEmbed; 58 - type PostEmbed = VideoEmbedPost | ImagesEmbed; 59 - 60 - export class VideoEmbedImpl implements VideoEmbed { 61 - readonly $type = "app.bsky.embed.video"; 62 - 63 - constructor( 64 - public alt: string, 65 - public buffer: Buffer, 66 - public mimeType: string, 67 - public size?: number, 68 - public video?: { 69 - ref: BlobRef; 70 - mimeType: string; 71 - size: number; 72 - } 73 - ) {} 74 - 75 - toJSON() { 76 - return { 77 - $type: this.$type, 78 - alt: this.alt, 79 - buffer: "[Buffer length=" + this.buffer.length + "]", 80 - mimeType: this.mimeType, 81 - size: this.size, 82 - video: this.video 83 - }; 84 - } 85 - } 86 - 87 - export class BlueskyClient { 88 - private readonly agent: AtpAgent; 89 - private readonly username: string; 90 - private readonly password: string; 91 - 92 - constructor(username: string, password: string) { 93 - this.agent = new AtpAgent({ service: "https://bsky.social" }); 94 - this.username = username; 95 - this.password = password; 96 - } 97 - 98 - async login(): Promise<void> { 99 - logger.debug("Authenitcating with Bluesky atproto."); 100 - try { 101 - await this.agent.login({ 102 - identifier: this.username, 103 - password: this.password, 104 - }); 105 - } catch (error) { 106 - logger.error("Authentication error"); 107 - throw error; 108 - } 109 - } 110 - 111 - /** 112 - * Upload video file and get blob reference 113 - */ 114 - async uploadVideo( 115 - buffer: Buffer, 116 - mimeType: string = "video/mp4" 117 - ): Promise<BlobRef> { 118 - try { 119 - logger.debug("Starting video upload process..."); 120 - const response = await this.agent.uploadBlob(buffer, { 121 - encoding: mimeType, 122 - }); 123 - 124 - if (!response?.data?.blob) { 125 - throw new Error("Failed to get video upload reference"); 126 - } 127 - 128 - return response.data.blob; 129 - } catch (error) { 130 - logger.error("Failed to upload video:", error); 131 - throw error; 132 - } 133 - } 134 - 135 - /** 136 - * Upload image file and get blob reference 137 - */ 138 - async uploadImage( 139 - buffer: Buffer, 140 - mimeType: string = "image/jpeg" 141 - ): Promise<BlobRef> { 142 - try { 143 - logger.debug("Uploading image..."); 144 - const response = await this.agent.uploadBlob(buffer, { 145 - encoding: mimeType, 146 - }); 147 - 148 - if (!response?.data?.blob) { 149 - throw new Error("Failed to get image upload reference"); 150 - } 151 - 152 - return response.data.blob; 153 - } catch (error) { 154 - logger.error("Failed to upload image:", error); 155 - throw error; 156 - } 157 - } 158 - 159 - private determineEmbed(embeddedMedia: EmbeddedMedia): PostEmbed | undefined { 160 - if (!embeddedMedia) return undefined; 161 - 162 - // Handle video embed 163 - if ( 164 - !Array.isArray(embeddedMedia) && 165 - embeddedMedia.$type === "app.bsky.embed.video" 166 - ) { 167 - return { 168 - $type: "app.bsky.embed.video", 169 - video: embeddedMedia.video!.ref, 170 - mimeType: embeddedMedia.mimeType, 171 - size: embeddedMedia.video!.size, 172 - }; 173 - } 174 - 175 - // Handle image embed(s) 176 - if (Array.isArray(embeddedMedia) && embeddedMedia.length > 0) { 177 - return { 178 - $type: "app.bsky.embed.images", 179 - images: embeddedMedia.map( 180 - (img) => 181 - new ImageEmbedImpl(img.alt, img.image as BlobRef, img.mimeType) 182 - ), 183 - }; 184 - } 185 - 186 - return undefined; 187 - } 188 - 189 - async createPost( 190 - postDate: Date, 191 - postText: string, 192 - embeddedMedia: any 193 - ): Promise<string | null> { 194 - try { 195 - // Handle image uploads if present 196 - if (Array.isArray(embeddedMedia)) { 197 - const uploadedImages = await Promise.all( 198 - embeddedMedia.map(async (media) => { 199 - const blob = await this.uploadImage(media.image, media.mimeType); 200 - return new ImageEmbedImpl(media.alt, blob, media.mimeType); 201 - }) 202 - ); 203 - 204 - embeddedMedia = { 205 - $type: "app.bsky.embed.images", 206 - images: uploadedImages, 207 - }; 208 - } else if (embeddedMedia?.$type === "app.bsky.embed.video") { 209 - // Upload video first 210 - const blob = await this.uploadVideo( 211 - embeddedMedia.buffer, 212 - embeddedMedia.mimeType 213 - ); 214 - embeddedMedia.video = { 215 - ref: blob, 216 - mimeType: embeddedMedia.mimeType, 217 - size: embeddedMedia.buffer.length, 218 - }; 219 - // Now transform the embed 220 - embeddedMedia = this.determineEmbed(embeddedMedia); 221 - } 222 - 223 - const rt = new RichText({ text: postText }); 224 - await rt.detectFacets(this.agent); 225 - 226 - const postRecord = { 227 - $type: "app.bsky.feed.post", 228 - text: rt.text, 229 - facets: rt.facets, 230 - createdAt: postDate.toISOString(), 231 - embed: embeddedMedia, 232 - }; 233 - 234 - const recordData = await this.agent.post(postRecord); 235 - const i = recordData.uri.lastIndexOf("/"); 236 - if (i > 0) { 237 - const rkey = recordData.uri.substring(i + 1); 238 - return `https://bsky.app/profile/${this.username}/post/${rkey}`; 239 - } 240 - logger.warn(recordData); 241 - return null; 242 - } catch (error) { 243 - logger.error("Failed to create post:", error); 244 - return null; 245 - } 246 - } 247 - }
+2
src/bluesky/README.md
··· 1 + ## Bluesky client 2 + API with the Bluesky ATProto agent. All Bluesky logic needs to remain here.
+251
src/bluesky/bluesky.ts
··· 1 + import { 2 + AtpAgent, 3 + RichText, 4 + BlobRef, 5 + AppBskyFeedPost, 6 + AppBskyEmbedDefs, 7 + AppBskyRichtextFacet, 8 + AppBskyEmbedVideo, 9 + AppBskyEmbedImages, 10 + } from "@atproto/api"; 11 + import { logger } from "../logger/logger"; 12 + 13 + export interface VideoEmbed extends AppBskyEmbedVideo.Main { 14 + $type: "app.bsky.embed.video"; 15 + buffer: Buffer; 16 + mimeType: string; 17 + video: BlobRef; 18 + size?: number; 19 + captions?: AppBskyEmbedVideo.Caption[]; 20 + /** Alt text description of the video, for accessibility. */ 21 + alt?: string; 22 + aspectRatio?: AppBskyEmbedDefs.AspectRatio; 23 + } 24 + 25 + export interface ImageEmbed extends AppBskyEmbedImages.Image { 26 + $type: "app.bsky.embed.images#image"; 27 + alt: string; 28 + image: BlobRef; 29 + mimeType: string; 30 + // Remove Buffer and Blob - we'll convert during upload 31 + uploadData?: Buffer | Blob; 32 + } 33 + 34 + export class ImageEmbedImpl implements ImageEmbed { 35 + readonly $type = "app.bsky.embed.images#image"; 36 + [k: string]: unknown; 37 + 38 + constructor( 39 + public alt: string, 40 + public image: BlobRef, 41 + public mimeType: string, 42 + public uploadData?: Buffer | Blob 43 + ) {} 44 + 45 + toJSON() { 46 + return { 47 + $type: this.$type, 48 + alt: this.alt, 49 + image: this.image, 50 + }; 51 + } 52 + } 53 + 54 + export interface ImagesEmbed extends AppBskyEmbedImages.Main { 55 + $type: "app.bsky.embed.images"; 56 + images: ImageEmbed[]; 57 + } 58 + 59 + type EmbeddedMedia = VideoEmbed | ImagesEmbed; 60 + 61 + export class VideoEmbedImpl implements VideoEmbed { 62 + readonly $type = "app.bsky.embed.video"; 63 + [k: string]: unknown; 64 + 65 + constructor( 66 + public alt: string | undefined, 67 + public buffer: Buffer, 68 + public mimeType: string, 69 + public size: number | undefined, 70 + public video: BlobRef, 71 + public aspectRatio?: AppBskyEmbedDefs.AspectRatio, 72 + public captions?: AppBskyEmbedVideo.Caption[] 73 + ) {} 74 + 75 + toJSON() { 76 + return { 77 + $type: this.$type, 78 + alt: this.alt, 79 + buffer: "[Buffer length=" + this.buffer.length + "]", 80 + mimeType: this.mimeType, 81 + size: this.size, 82 + video: this.video, 83 + }; 84 + } 85 + } 86 + 87 + export class ImagesEmbedImpl implements ImagesEmbed { 88 + readonly $type = "app.bsky.embed.images"; 89 + [k: string]: unknown; 90 + 91 + constructor(public images: ImageEmbed[]) {} 92 + } 93 + 94 + export interface PostRecord extends Partial<AppBskyFeedPost.Record> {} 95 + 96 + /** 97 + * Simple bluesky record. 98 + * @see AppBskyFeedPost.Record 99 + */ 100 + export class PostRecordImpl implements PostRecord { 101 + readonly $type = "app.bsky.feed.post"; 102 + [k: string]: unknown; 103 + 104 + constructor( 105 + public text: string, 106 + public createdAt: string, 107 + public facets: AppBskyRichtextFacet.Main[], 108 + public embed: EmbeddedMedia 109 + ) {} 110 + } 111 + 112 + export class BlueskyClient { 113 + private readonly agent: AtpAgent; 114 + private readonly username: string; 115 + private readonly password: string; 116 + 117 + constructor(username: string, password: string) { 118 + this.agent = new AtpAgent({ service: "https://bsky.social" }); 119 + this.username = username; 120 + this.password = password; 121 + } 122 + 123 + async login(): Promise<void> { 124 + logger.debug("Authenitcating with Bluesky atproto."); 125 + try { 126 + await this.agent.login({ 127 + identifier: this.username, 128 + password: this.password, 129 + }); 130 + } catch (error) { 131 + logger.error("Authentication error"); 132 + throw error; 133 + } 134 + } 135 + 136 + /** 137 + * Upload video file and get blob reference 138 + */ 139 + async uploadVideo( 140 + buffer: Buffer, 141 + mimeType: string = "video/mp4" 142 + ): Promise<BlobRef> { 143 + try { 144 + logger.debug("Starting video upload process..."); 145 + const response = await this.agent.uploadBlob(buffer, { 146 + encoding: mimeType, 147 + }); 148 + 149 + if (!response?.data?.blob) { 150 + throw new Error("Failed to get video upload reference"); 151 + } 152 + 153 + return response.data.blob; 154 + } catch (error) { 155 + logger.error("Failed to upload video:", error); 156 + throw error; 157 + } 158 + } 159 + 160 + /** 161 + * Upload image file and get blob reference 162 + */ 163 + async uploadImage( 164 + buffer: Buffer | Blob, 165 + mimeType: string = "image/jpeg" 166 + ): Promise<BlobRef> { 167 + try { 168 + logger.debug("Uploading image..."); 169 + const response = await this.agent.uploadBlob(buffer, { 170 + encoding: mimeType, 171 + }); 172 + 173 + if (!response?.data?.blob) { 174 + throw new Error("Failed to get image upload reference"); 175 + } 176 + 177 + return response.data.blob; 178 + } catch (error) { 179 + logger.error("Failed to upload image:", error); 180 + throw error; 181 + } 182 + } 183 + 184 + async createPost( 185 + postDate: Date, 186 + postText: string, 187 + embeddedMedia: EmbeddedMedia 188 + ): Promise<string | null> { 189 + try { 190 + // Handle image uploads if present 191 + if (Array.isArray(embeddedMedia) && AppBskyEmbedImages.isImage(embeddedMedia[0])) { 192 + const imagesMedia: ImageEmbed[] = embeddedMedia; 193 + const uploadedImages = await Promise.all( 194 + imagesMedia.map(async (media) => { 195 + const blob = await this.uploadImage( 196 + media.image, 197 + media.mimeType 198 + ); 199 + return new ImageEmbedImpl( 200 + media.alt, 201 + blob, 202 + media.mimeType, 203 + media.uploadData 204 + ); 205 + }) 206 + ); 207 + 208 + embeddedMedia = new ImagesEmbedImpl(uploadedImages); 209 + } else if (AppBskyEmbedVideo.isMain(embeddedMedia)) { 210 + // Upload video first 211 + const videoBlobRef = await this.uploadVideo( 212 + embeddedMedia.buffer, 213 + embeddedMedia.mimeType 214 + ); 215 + // Now transform the embed 216 + embeddedMedia = new VideoEmbedImpl( 217 + "", 218 + embeddedMedia.buffer, 219 + embeddedMedia.mimeType, 220 + embeddedMedia.size, 221 + videoBlobRef, 222 + embeddedMedia.aspectRatio, 223 + embeddedMedia.captions 224 + ); 225 + } 226 + 227 + const rt = new RichText({ text: postText }); 228 + await rt.detectFacets(this.agent); 229 + 230 + // create blsky post record. 231 + const postRecord = new PostRecord( 232 + rt.text, 233 + postDate.toISOString(), 234 + rt.facets, 235 + embeddedMedia 236 + ); 237 + 238 + const recordData = await this.agent.post(postRecord); 239 + const i = recordData.uri.lastIndexOf("/"); 240 + if (i > 0) { 241 + const rkey = recordData.uri.substring(i + 1); 242 + return `https://bsky.app/profile/${this.username}/post/${rkey}`; 243 + } 244 + logger.warn(recordData); 245 + return null; 246 + } catch (error) { 247 + logger.error(`Failed to create post: ${error}`); 248 + return null; 249 + } 250 + } 251 + }
+2
src/image/README.md
··· 1 + # Image utils 2 + `image.ts` is for image processing utils. Converting instagram images into a format ready for the media processor to send to the bluesky client.
+108
src/image/image.ts
··· 1 + import sharp from "sharp"; 2 + import byteSize from "byte-size"; 3 + import { logger } from "../logger/logger"; 4 + 5 + /** 6 + * Image lexicon maxSize 1mb 7 + * @link https://github.com/bluesky-social/atproto/blob/f90eedc865136f50a9daee72c52b275d26310aa3/lexicons/app/bsky/embed/images.json#L24 8 + */ 9 + export const API_LIMIT_IMAGE_UPLOAD_SIZE = 976000; 10 + const IMAGE_LENGTH_LIMIT = 1920; 11 + 12 + export function isImageMimeType(mimeType: string): boolean { 13 + return mimeType.startsWith('image/'); 14 + } 15 + 16 + export function getImageMimeType(fileType: string): string { 17 + switch (fileType.toLowerCase()) { 18 + case "heic": 19 + return "image/heic"; 20 + case "webp": 21 + return "image/webp"; 22 + case "jpg": 23 + case "jpeg": 24 + return "image/jpeg"; 25 + case "png": 26 + return "image/png"; 27 + default: 28 + return ""; 29 + } 30 + } 31 + 32 + /** 33 + * Checks if the buffer size exceeds Bluesky's upload limit 34 + */ 35 + export function isImageTooLarge(buffer: Buffer): boolean { 36 + return buffer.length > API_LIMIT_IMAGE_UPLOAD_SIZE; 37 + } 38 + 39 + /** 40 + * Validates and resizes image if needed to meet Bluesky's upload requirements 41 + * @returns Buffer | null 42 + */ 43 + export async function processImageBuffer(mediaBuffer: Buffer, filename: string): Promise<Buffer | null> { 44 + if (!isImageTooLarge(mediaBuffer)) { 45 + return mediaBuffer; 46 + } 47 + 48 + logger.warn({ 49 + message: `Image size (${byteSize(mediaBuffer.length)}) is larger than upload limit (${byteSize( 50 + API_LIMIT_IMAGE_UPLOAD_SIZE 51 + )}). Will attempt to resize buffer ${filename}`, 52 + }); 53 + 54 + try { 55 + const sharpImage = sharp(mediaBuffer); 56 + const imageMeta = await sharpImage.metadata(); 57 + 58 + if (!imageMeta.width || !imageMeta.height) { 59 + logger.error({ 60 + message: `Image width or height meta data is missing, image buffer cannot be resized.`, 61 + }); 62 + return null; 63 + } 64 + 65 + let width: number | undefined = 66 + imageMeta.width > imageMeta.height ? IMAGE_LENGTH_LIMIT : undefined; 67 + const height: number | undefined = 68 + imageMeta.height > imageMeta.width ? IMAGE_LENGTH_LIMIT : undefined; 69 + 70 + // both will be undefined if the image is square, so set the width. 71 + if (!width && !height) { 72 + width = IMAGE_LENGTH_LIMIT; 73 + } 74 + 75 + const bufferResized = await sharp(mediaBuffer) 76 + .resize({ width: width, height: height, withoutEnlargement: true }) 77 + .toBuffer(); 78 + 79 + const metaResized = await sharp(bufferResized).metadata(); 80 + 81 + logger.info({ 82 + message: `before: w${imageMeta.width} h${imageMeta.height} | after: w${metaResized.width} h${metaResized.height}`, 83 + }); 84 + 85 + if (bufferResized.length > API_LIMIT_IMAGE_UPLOAD_SIZE) { 86 + logger.error({ 87 + message: `Resized image size (${byteSize(bufferResized.length)}) is larger than image upload limit (${byteSize( 88 + API_LIMIT_IMAGE_UPLOAD_SIZE 89 + )})`, 90 + }); 91 + return null; 92 + } 93 + 94 + logger.info({ 95 + message: `Image successfully resized (${byteSize(bufferResized.length)}) to be less than upload limit (${byteSize( 96 + API_LIMIT_IMAGE_UPLOAD_SIZE 97 + )}). This does not change the original image on disk.`, 98 + }); 99 + 100 + return bufferResized; 101 + } catch (error) { 102 + logger.error({ 103 + message: `Failed to process image: ${filename}`, 104 + error, 105 + }); 106 + return null; 107 + } 108 + }
src/logger.ts src/logger/logger.ts
+1 -1
src/main.ts
··· 1 - import { main } from "./app"; 1 + import { main } from "./instagram-to-bluesky"; 2 2 3 3 (async () => { 4 4 await main();
+43 -18
src/media.ts src/media/media.ts
··· 1 1 import { 2 - ImageEmbed, 3 - VideoEmbed, 4 - ImageEmbedImpl, 5 - VideoEmbedImpl, 6 2 BlueskyClient, 7 - } from "./bluesky"; 8 - import { logger } from "./logger"; 9 - import { validateVideo, processVideoPost } from "./video"; 3 + } from "../bluesky/bluesky"; 4 + import { logger } from "../logger/logger"; 5 + import { validateVideo, processVideoPost } from "../video/video"; 10 6 import FS from "fs"; 11 7 12 8 export interface MediaProcessResult { ··· 16 12 isVideo: boolean; 17 13 } 18 14 15 + /** 16 + * Processed media from instagram post that supports logging. 17 + */ 18 + export class MediaProcessResultImpl implements MediaProcessResult { 19 + constructor( 20 + public mediaText: string, 21 + public mimeType: string | null, 22 + public mediaBuffer: Buffer | null, 23 + public isVideo: boolean 24 + ) {} 25 + 26 + toJSON() { 27 + return { 28 + mediaText: this.mediaText, 29 + mimeType: this.mimeType, 30 + mediaBuffer: this.mediaBuffer ? "[Buffer length=" + this.mediaBuffer.length + "]" : null, 31 + isVideo: this.isVideo 32 + }; 33 + } 34 + } 35 + 36 + /** 37 + * Instagram post thats been processed to be transformed into a Bluesky post. 38 + */ 19 39 export interface ProcessedPost { 20 40 postDate: Date | null; 21 41 postText: string; 22 - embeddedMedia: VideoEmbed | ImageEmbed[]; 42 + embeddedMedia: MediaProcessResult | MediaProcessResult[]; 23 43 mediaCount: number; 24 44 } 25 45 ··· 62 82 message: `Failed to read media file: ${mediaFilename}`, 63 83 error, 64 84 }); 65 - return { mediaText: "", mimeType: null, mediaBuffer: null, isVideo: false }; 85 + return new MediaProcessResultImpl("", null, null, false); 66 86 } 67 87 68 88 let mediaText = media.title ?? ""; ··· 87 107 Type: isVideo ? "Video" : "Image", 88 108 }); 89 109 90 - return { mediaText: truncatedText, mimeType, mediaBuffer, isVideo }; 110 + return new MediaProcessResultImpl(truncatedText, mimeType, mediaBuffer, isVideo); 91 111 } 92 112 93 113 export async function processPost( ··· 123 143 postDate = postDate || new Date(post.media[0].creation_timestamp * 1000); 124 144 } 125 145 126 - let embeddedMedia: ImageEmbed[] = []; 146 + let embeddedMedia: MediaProcessResult[] = []; 127 147 let mediaCount = 0; 128 148 129 149 // If first media is video, process only that 130 150 const firstMedia = await processMedia(post.media[0], archiveFolder); 131 151 if (firstMedia.isVideo) { 132 - let embeddedVideo: VideoEmbed; 152 + let embeddedVideo: MediaProcessResult; 133 153 if ( 134 154 firstMedia.mimeType && 135 155 firstMedia.mediaBuffer && 136 156 validateVideo(firstMedia.mediaBuffer) 137 157 ) { 138 - embeddedVideo = new VideoEmbedImpl( 158 + embeddedVideo = new MediaProcessResultImpl( 139 159 firstMedia.mediaText, 160 + firstMedia.mimeType, 140 161 firstMedia.mediaBuffer, 141 - firstMedia.mimeType 162 + true 142 163 ); 143 164 mediaCount = 1; 144 165 // Handle video if present 145 166 try { 146 167 const videoEmbed = await processVideoPost( 147 168 post.media[0].uri, 148 - embeddedVideo.buffer, 169 + firstMedia.mediaBuffer, 149 170 bluesky, 150 171 simulate 151 172 ); 152 173 153 - // TODO fix typing errors 154 - embeddedVideo = videoEmbed as unknown as VideoEmbed; 174 + embeddedVideo = videoEmbed as MediaProcessResult; 155 175 logger.debug({ 156 176 message: "Video processing complete", 157 177 hasVideoEmbed: !!videoEmbed, ··· 185 205 if (!mimeType || !mediaBuffer || isVideo) continue; 186 206 187 207 embeddedMedia.push( 188 - new ImageEmbedImpl(mediaText, mediaBuffer, mimeType) 208 + new MediaProcessResultImpl( 209 + mediaText, 210 + mimeType, 211 + mediaBuffer, 212 + false 213 + ) 189 214 ); 190 215 mediaCount++; 191 216 }
+2
src/media/README.md
··· 1 + # Media 2 + `media.ts` is responsible for delegating to media processors like `image.ts`, `video.ts` and transforming its outputs into something suitable for the BlueSkyClient in `bluesky.ts`.
-145
src/video.ts
··· 1 - import ffmpeg from 'fluent-ffmpeg'; 2 - import ffprobe from '@ffprobe-installer/ffprobe'; 3 - import { logger } from './logger' 4 - import { BlueskyClient } from './bluesky'; 5 - 6 - // Configure ffmpeg to use ffprobe 7 - ffmpeg.setFfprobePath(ffprobe.path); 8 - 9 - /** 10 - * Validates video size is not greater than Blueskys max. 11 - * @returns boolean 12 - */ 13 - export function validateVideo(buffer: Buffer): boolean { 14 - const MAX_SIZE = 100 * 1024 * 1024; // 100MB 15 - logger.debug(`Validating video size: ${Math.round(buffer.length / 1024 / 1024)}MB`); 16 - if (buffer.length > MAX_SIZE) { 17 - logger.warn(`Video file too large: ${Math.round(buffer.length / 1024 / 1024)}MB (max ${MAX_SIZE / 1024 / 1024}MB)`); 18 - return false; 19 - } 20 - return true; 21 - } 22 - 23 - /** 24 - * Uses FFMpeg to resolve the video dimensions. 25 - * @returns Promise<{width: number, height: number}> 26 - */ 27 - export async function getVideoDimensions(filePath: string): Promise<{width: number, height: number}> { 28 - logger.debug(`Getting video dimensions for: ${filePath}`); 29 - return new Promise((resolve, reject) => { 30 - ffmpeg.ffprobe(filePath, (err: Error, metadata) => { 31 - if (err) { 32 - logger.error(`FFprobe error: ${err.message}`); 33 - reject(err); 34 - return; 35 - } 36 - 37 - const videoStream = metadata.streams.find(s => s.codec_type === 'video'); 38 - if (!videoStream) { 39 - logger.error('No video stream found in file'); 40 - reject(new Error('No video stream found')); 41 - return; 42 - } 43 - 44 - const dimensions = { 45 - width: videoStream.width || 640, 46 - height: videoStream.height || 640 47 - }; 48 - logger.debug(`Video dimensions: ${dimensions.width}x${dimensions.height}`); 49 - resolve(dimensions); 50 - }); 51 - }); 52 - } 53 - 54 - /** 55 - * Prepares video for Bluesky upload by creating required metadata 56 - * @returns Promise<{ref: string, mimeType: string, size: number, dimensions: {width: number, height: number}}> 57 - */ 58 - export async function prepareVideoUpload(filePath: string, buffer: Buffer): Promise<{ 59 - ref: string, 60 - mimeType: string, 61 - size: number, 62 - dimensions: {width: number, height: number} 63 - }> { 64 - // Validate video size 65 - if (!validateVideo(buffer)) { 66 - throw new Error('Video validation failed'); 67 - } 68 - 69 - // Get video dimensions 70 - const dimensions = await getVideoDimensions(filePath); 71 - 72 - // Return video metadata in Bluesky format 73 - return { 74 - ref: '', // This will be filled by the upload process with the CID 75 - mimeType: 'video/mp4', 76 - size: buffer.length, 77 - dimensions 78 - }; 79 - } 80 - 81 - /** 82 - * Creates the video embed structure for Bluesky post 83 - */ 84 - export function createVideoEmbed(videoData: { 85 - ref: string, 86 - mimeType: string, 87 - size: number, 88 - dimensions: {width: number, height: number} 89 - }) { 90 - return { 91 - $type: "app.bsky.embed.video", 92 - video: { 93 - $type: "blob", 94 - ref: { 95 - $link: videoData.ref 96 - }, 97 - mimeType: videoData.mimeType, 98 - size: videoData.size 99 - }, 100 - aspectRatio: { 101 - width: videoData.dimensions.width, 102 - height: videoData.dimensions.height 103 - } 104 - }; 105 - } 106 - 107 - 108 - export async function processVideoPost( 109 - filePath: string, 110 - buffer: Buffer, 111 - bluesky: BlueskyClient | null, 112 - simulate: boolean 113 - ) { 114 - try { 115 - if (!buffer) { 116 - throw new Error("Video buffer is undefined"); 117 - } 118 - 119 - logger.debug({ 120 - message: "Processing video", 121 - fileSize: buffer.length, 122 - filePath, 123 - }); 124 - 125 - // Prepare video metadata 126 - const videoData = await prepareVideoUpload(filePath, buffer); 127 - 128 - // Upload video to get CID 129 - if (!simulate && bluesky) { 130 - const blob = await bluesky.uploadVideo(buffer); 131 - if (!blob?.ref?.$link) { 132 - throw new Error("Failed to get video upload reference"); 133 - } 134 - videoData.ref = blob.ref.$link; 135 - } 136 - 137 - // Create video embed structure 138 - const videoEmbed = createVideoEmbed(videoData); 139 - 140 - return videoEmbed; 141 - } catch (error) { 142 - logger.error("Failed to process video:", error); 143 - throw error; 144 - } 145 - }
+2
src/video/README.ms
··· 1 + # Video Utils 2 + `video.ts` is for all video processing utils unrelated to the Bluesky protocol.
+205
src/video/video.ts
··· 1 + import ffmpeg from 'fluent-ffmpeg'; 2 + import ffprobe from '@ffprobe-installer/ffprobe'; 3 + import { logger } from '../logger/logger' 4 + import { BlueskyClient, VideoEmbed } from '../bluesky/bluesky'; 5 + import { BlobRef } from '@atproto/api'; 6 + 7 + // Configure ffmpeg to use ffprobe 8 + ffmpeg.setFfprobePath(ffprobe.path); 9 + 10 + /** 11 + * Validates video size is not greater than Blueskys max. 12 + * @returns boolean 13 + */ 14 + export function validateVideo(buffer: Buffer): boolean { 15 + const MAX_SIZE = 100 * 1024 * 1024; // 100MB 16 + logger.debug(`Validating video size: ${Math.round(buffer.length / 1024 / 1024)}MB`); 17 + if (buffer.length > MAX_SIZE) { 18 + logger.warn(`Video file too large: ${Math.round(buffer.length / 1024 / 1024)}MB (max ${MAX_SIZE / 1024 / 1024}MB)`); 19 + return false; 20 + } 21 + return true; 22 + } 23 + 24 + /** 25 + * Uses FFMpeg to resolve the video dimensions. 26 + * @returns Promise<{width: number, height: number}> 27 + */ 28 + export async function getVideoDimensions(filePath: string): Promise<{width: number, height: number}> { 29 + logger.debug(`Getting video dimensions for: ${filePath}`); 30 + return new Promise((resolve, reject) => { 31 + ffmpeg.ffprobe(filePath, (err: Error, metadata) => { 32 + if (err) { 33 + logger.error(`FFprobe error: ${err.message}`); 34 + reject(err); 35 + return; 36 + } 37 + 38 + const videoStream = metadata.streams.find(s => s.codec_type === 'video'); 39 + if (!videoStream) { 40 + logger.error('No video stream found in file'); 41 + reject(new Error('No video stream found')); 42 + return; 43 + } 44 + 45 + const dimensions = { 46 + width: videoStream.width || 640, 47 + height: videoStream.height || 640 48 + }; 49 + logger.debug(`Video dimensions: ${dimensions.width}x${dimensions.height}`); 50 + resolve(dimensions); 51 + }); 52 + }); 53 + } 54 + 55 + export interface VideoUploadData { 56 + ref: BlobRef | undefined; 57 + mimeType: string; 58 + size: number; 59 + dimensions: { 60 + width: number; 61 + height: number; 62 + }; 63 + } 64 + 65 + export class VideoUploadDataImpl implements VideoUploadData { 66 + constructor( 67 + public ref: BlobRef | undefined, 68 + public mimeType: string, 69 + public size: number, 70 + public dimensions: { 71 + width: number; 72 + height: number; 73 + } 74 + ) {} 75 + 76 + static createDefault(buffer: Buffer): VideoUploadDataImpl { 77 + return new VideoUploadDataImpl( 78 + undefined, // empty ref to be filled later 79 + 'video/mp4', 80 + buffer.length, 81 + { width: 640, height: 640 } 82 + ); 83 + } 84 + } 85 + 86 + // TODO not setting a blobref screams the wrong place blobref is for bluesky not video processing. 87 + export async function prepareVideoUpload(filePath: string, buffer: Buffer): Promise<VideoUploadData> { 88 + if (!validateVideo(buffer)) { 89 + throw new Error('Video validation failed'); 90 + } 91 + 92 + return VideoUploadDataImpl.createDefault(buffer); 93 + } 94 + 95 + export interface VideoEmbedOutput { 96 + $type: "app.bsky.embed.video"; 97 + video: { 98 + $type: string; 99 + ref: { $link: string }; 100 + mimeType: string; 101 + size: number; 102 + }; 103 + aspectRatio: { 104 + width: number; 105 + height: number; 106 + }; 107 + } 108 + 109 + export class VideoEmbedOutputImpl implements VideoEmbedOutput { 110 + readonly $type = "app.bsky.embed.video"; 111 + readonly video: { 112 + $type: string; 113 + ref: { $link: string }; 114 + mimeType: string; 115 + size: number; 116 + }; 117 + readonly aspectRatio: { 118 + width: number; 119 + height: number; 120 + }; 121 + 122 + constructor( 123 + ref: string, 124 + mimeType: string, 125 + size: number, 126 + dimensions: { width: number; height: number } 127 + ) { 128 + this.video = { 129 + $type: "blob", 130 + ref: { $link: ref }, 131 + mimeType, 132 + size 133 + }; 134 + this.aspectRatio = dimensions; 135 + } 136 + } 137 + 138 + /** 139 + * Creates the video embed structure for Bluesky post 140 + */ 141 + export function createVideoEmbed(videoData: { 142 + ref: string, 143 + mimeType: string, 144 + size: number, 145 + dimensions: {width: number, height: number} 146 + }): VideoEmbedOutput { 147 + return new VideoEmbedOutputImpl( 148 + videoData.ref, 149 + videoData.mimeType, 150 + videoData.size, 151 + videoData.dimensions 152 + ); 153 + } 154 + 155 + /** 156 + * Processes a video file for posting to Bluesky, including metadata preparation and upload 157 + * 158 + * @param filePath - The path to the video file being processed 159 + * @param buffer - The video file contents as a Buffer 160 + * @param bluesky - BlueskyClient instance for uploading, or null if not uploading 161 + * @param simulate - If true, skips the actual upload to Bluesky 162 + * 163 + * @returns A video embed structure ready for posting to Bluesky 164 + * @throws {Error} If video buffer is undefined or upload fails 165 + */ 166 + export async function processVideoPost( 167 + filePath: string, 168 + buffer: Buffer, 169 + bluesky: BlueskyClient | null, 170 + simulate: boolean 171 + ) { 172 + try { 173 + if (!buffer) { 174 + throw new Error("Video buffer is undefined"); 175 + } 176 + 177 + logger.debug({ 178 + message: "Processing video", 179 + fileSize: buffer.length, 180 + filePath, 181 + }); 182 + 183 + // Prepare video metadata 184 + const videoData = await prepareVideoUpload(filePath, buffer); 185 + 186 + // Upload video to get CID 187 + if (!simulate && bluesky) { 188 + 189 + // TODO isolate this logic and remove it only being placed into the media directory. 190 + const blob = await bluesky.uploadVideo(buffer); 191 + if (!blob?.ref) { 192 + throw new Error("Failed to get video upload reference"); 193 + } 194 + videoData.ref.$link = blob.ref.$link; 195 + } 196 + 197 + // Create video embed structure 198 + const videoEmbed = createVideoEmbed(videoData); 199 + 200 + return videoEmbed; 201 + } catch (error) { 202 + logger.error("Failed to process video:", error); 203 + throw error; 204 + } 205 + }