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.
···11+# Main
22+33+`main.ts` is the entry point that runs the async application code located in `instagram-to-bluesky.ts`.
44+55+## Instagram to Bluesky
66+77+`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
···33import path from 'path';
44import * as process from 'process';
5566-import { BlueskyClient } from './bluesky';
77-import { logger } from './logger';
88-import { processPost } from './media';
99-import { createVideoEmbed, prepareVideoUpload } from './video';
66+import { BlueskyClient } from './bluesky/bluesky';
77+import { logger } from './logger/logger';
88+import { processPost } from './media/media';
99+import { createVideoEmbed, prepareVideoUpload } from './video/video';
10101111dotenv.config();
1212
···11+# Image utils
22+`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
···11+import sharp from "sharp";
22+import byteSize from "byte-size";
33+import { logger } from "../logger/logger";
44+55+/**
66+ * Image lexicon maxSize 1mb
77+ * @link https://github.com/bluesky-social/atproto/blob/f90eedc865136f50a9daee72c52b275d26310aa3/lexicons/app/bsky/embed/images.json#L24
88+ */
99+export const API_LIMIT_IMAGE_UPLOAD_SIZE = 976000;
1010+const IMAGE_LENGTH_LIMIT = 1920;
1111+1212+export function isImageMimeType(mimeType: string): boolean {
1313+ return mimeType.startsWith('image/');
1414+}
1515+1616+export function getImageMimeType(fileType: string): string {
1717+ switch (fileType.toLowerCase()) {
1818+ case "heic":
1919+ return "image/heic";
2020+ case "webp":
2121+ return "image/webp";
2222+ case "jpg":
2323+ case "jpeg":
2424+ return "image/jpeg";
2525+ case "png":
2626+ return "image/png";
2727+ default:
2828+ return "";
2929+ }
3030+}
3131+3232+/**
3333+ * Checks if the buffer size exceeds Bluesky's upload limit
3434+ */
3535+export function isImageTooLarge(buffer: Buffer): boolean {
3636+ return buffer.length > API_LIMIT_IMAGE_UPLOAD_SIZE;
3737+}
3838+3939+/**
4040+ * Validates and resizes image if needed to meet Bluesky's upload requirements
4141+ * @returns Buffer | null
4242+ */
4343+export async function processImageBuffer(mediaBuffer: Buffer, filename: string): Promise<Buffer | null> {
4444+ if (!isImageTooLarge(mediaBuffer)) {
4545+ return mediaBuffer;
4646+ }
4747+4848+ logger.warn({
4949+ message: `Image size (${byteSize(mediaBuffer.length)}) is larger than upload limit (${byteSize(
5050+ API_LIMIT_IMAGE_UPLOAD_SIZE
5151+ )}). Will attempt to resize buffer ${filename}`,
5252+ });
5353+5454+ try {
5555+ const sharpImage = sharp(mediaBuffer);
5656+ const imageMeta = await sharpImage.metadata();
5757+5858+ if (!imageMeta.width || !imageMeta.height) {
5959+ logger.error({
6060+ message: `Image width or height meta data is missing, image buffer cannot be resized.`,
6161+ });
6262+ return null;
6363+ }
6464+6565+ let width: number | undefined =
6666+ imageMeta.width > imageMeta.height ? IMAGE_LENGTH_LIMIT : undefined;
6767+ const height: number | undefined =
6868+ imageMeta.height > imageMeta.width ? IMAGE_LENGTH_LIMIT : undefined;
6969+7070+ // both will be undefined if the image is square, so set the width.
7171+ if (!width && !height) {
7272+ width = IMAGE_LENGTH_LIMIT;
7373+ }
7474+7575+ const bufferResized = await sharp(mediaBuffer)
7676+ .resize({ width: width, height: height, withoutEnlargement: true })
7777+ .toBuffer();
7878+7979+ const metaResized = await sharp(bufferResized).metadata();
8080+8181+ logger.info({
8282+ message: `before: w${imageMeta.width} h${imageMeta.height} | after: w${metaResized.width} h${metaResized.height}`,
8383+ });
8484+8585+ if (bufferResized.length > API_LIMIT_IMAGE_UPLOAD_SIZE) {
8686+ logger.error({
8787+ message: `Resized image size (${byteSize(bufferResized.length)}) is larger than image upload limit (${byteSize(
8888+ API_LIMIT_IMAGE_UPLOAD_SIZE
8989+ )})`,
9090+ });
9191+ return null;
9292+ }
9393+9494+ logger.info({
9595+ message: `Image successfully resized (${byteSize(bufferResized.length)}) to be less than upload limit (${byteSize(
9696+ API_LIMIT_IMAGE_UPLOAD_SIZE
9797+ )}). This does not change the original image on disk.`,
9898+ });
9999+100100+ return bufferResized;
101101+ } catch (error) {
102102+ logger.error({
103103+ message: `Failed to process image: ${filename}`,
104104+ error,
105105+ });
106106+ return null;
107107+ }
108108+}
src/logger.ts
src/logger/logger.ts
+1-1
src/main.ts
···11-import { main } from "./app";
11+import { main } from "./instagram-to-bluesky";
2233(async () => {
44 await main();
+43-18
src/media.ts
src/media/media.ts
···11import {
22- ImageEmbed,
33- VideoEmbed,
44- ImageEmbedImpl,
55- VideoEmbedImpl,
62 BlueskyClient,
77-} from "./bluesky";
88-import { logger } from "./logger";
99-import { validateVideo, processVideoPost } from "./video";
33+} from "../bluesky/bluesky";
44+import { logger } from "../logger/logger";
55+import { validateVideo, processVideoPost } from "../video/video";
106import FS from "fs";
117128export interface MediaProcessResult {
···1612 isVideo: boolean;
1713}
18141515+/**
1616+ * Processed media from instagram post that supports logging.
1717+ */
1818+export class MediaProcessResultImpl implements MediaProcessResult {
1919+ constructor(
2020+ public mediaText: string,
2121+ public mimeType: string | null,
2222+ public mediaBuffer: Buffer | null,
2323+ public isVideo: boolean
2424+ ) {}
2525+2626+ toJSON() {
2727+ return {
2828+ mediaText: this.mediaText,
2929+ mimeType: this.mimeType,
3030+ mediaBuffer: this.mediaBuffer ? "[Buffer length=" + this.mediaBuffer.length + "]" : null,
3131+ isVideo: this.isVideo
3232+ };
3333+ }
3434+}
3535+3636+/**
3737+ * Instagram post thats been processed to be transformed into a Bluesky post.
3838+ */
1939export interface ProcessedPost {
2040 postDate: Date | null;
2141 postText: string;
2222- embeddedMedia: VideoEmbed | ImageEmbed[];
4242+ embeddedMedia: MediaProcessResult | MediaProcessResult[];
2343 mediaCount: number;
2444}
2545···6282 message: `Failed to read media file: ${mediaFilename}`,
6383 error,
6484 });
6565- return { mediaText: "", mimeType: null, mediaBuffer: null, isVideo: false };
8585+ return new MediaProcessResultImpl("", null, null, false);
6686 }
67876888 let mediaText = media.title ?? "";
···87107 Type: isVideo ? "Video" : "Image",
88108 });
891099090- return { mediaText: truncatedText, mimeType, mediaBuffer, isVideo };
110110+ return new MediaProcessResultImpl(truncatedText, mimeType, mediaBuffer, isVideo);
91111}
9211293113export async function processPost(
···123143 postDate = postDate || new Date(post.media[0].creation_timestamp * 1000);
124144 }
125145126126- let embeddedMedia: ImageEmbed[] = [];
146146+ let embeddedMedia: MediaProcessResult[] = [];
127147 let mediaCount = 0;
128148129149 // If first media is video, process only that
130150 const firstMedia = await processMedia(post.media[0], archiveFolder);
131151 if (firstMedia.isVideo) {
132132- let embeddedVideo: VideoEmbed;
152152+ let embeddedVideo: MediaProcessResult;
133153 if (
134154 firstMedia.mimeType &&
135155 firstMedia.mediaBuffer &&
136156 validateVideo(firstMedia.mediaBuffer)
137157 ) {
138138- embeddedVideo = new VideoEmbedImpl(
158158+ embeddedVideo = new MediaProcessResultImpl(
139159 firstMedia.mediaText,
160160+ firstMedia.mimeType,
140161 firstMedia.mediaBuffer,
141141- firstMedia.mimeType
162162+ true
142163 );
143164 mediaCount = 1;
144165 // Handle video if present
145166 try {
146167 const videoEmbed = await processVideoPost(
147168 post.media[0].uri,
148148- embeddedVideo.buffer,
169169+ firstMedia.mediaBuffer,
149170 bluesky,
150171 simulate
151172 );
152173153153- // TODO fix typing errors
154154- embeddedVideo = videoEmbed as unknown as VideoEmbed;
174174+ embeddedVideo = videoEmbed as MediaProcessResult;
155175 logger.debug({
156176 message: "Video processing complete",
157177 hasVideoEmbed: !!videoEmbed,
···185205 if (!mimeType || !mediaBuffer || isVideo) continue;
186206187207 embeddedMedia.push(
188188- new ImageEmbedImpl(mediaText, mediaBuffer, mimeType)
208208+ new MediaProcessResultImpl(
209209+ mediaText,
210210+ mimeType,
211211+ mediaBuffer,
212212+ false
213213+ )
189214 );
190215 mediaCount++;
191216 }
+2
src/media/README.md
···11+# Media
22+`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
···11-import ffmpeg from 'fluent-ffmpeg';
22-import ffprobe from '@ffprobe-installer/ffprobe';
33-import { logger } from './logger'
44-import { BlueskyClient } from './bluesky';
55-66-// Configure ffmpeg to use ffprobe
77-ffmpeg.setFfprobePath(ffprobe.path);
88-99-/**
1010- * Validates video size is not greater than Blueskys max.
1111- * @returns boolean
1212- */
1313-export function validateVideo(buffer: Buffer): boolean {
1414- const MAX_SIZE = 100 * 1024 * 1024; // 100MB
1515- logger.debug(`Validating video size: ${Math.round(buffer.length / 1024 / 1024)}MB`);
1616- if (buffer.length > MAX_SIZE) {
1717- logger.warn(`Video file too large: ${Math.round(buffer.length / 1024 / 1024)}MB (max ${MAX_SIZE / 1024 / 1024}MB)`);
1818- return false;
1919- }
2020- return true;
2121-}
2222-2323-/**
2424- * Uses FFMpeg to resolve the video dimensions.
2525- * @returns Promise<{width: number, height: number}>
2626- */
2727-export async function getVideoDimensions(filePath: string): Promise<{width: number, height: number}> {
2828- logger.debug(`Getting video dimensions for: ${filePath}`);
2929- return new Promise((resolve, reject) => {
3030- ffmpeg.ffprobe(filePath, (err: Error, metadata) => {
3131- if (err) {
3232- logger.error(`FFprobe error: ${err.message}`);
3333- reject(err);
3434- return;
3535- }
3636-3737- const videoStream = metadata.streams.find(s => s.codec_type === 'video');
3838- if (!videoStream) {
3939- logger.error('No video stream found in file');
4040- reject(new Error('No video stream found'));
4141- return;
4242- }
4343-4444- const dimensions = {
4545- width: videoStream.width || 640,
4646- height: videoStream.height || 640
4747- };
4848- logger.debug(`Video dimensions: ${dimensions.width}x${dimensions.height}`);
4949- resolve(dimensions);
5050- });
5151- });
5252-}
5353-5454-/**
5555- * Prepares video for Bluesky upload by creating required metadata
5656- * @returns Promise<{ref: string, mimeType: string, size: number, dimensions: {width: number, height: number}}>
5757- */
5858-export async function prepareVideoUpload(filePath: string, buffer: Buffer): Promise<{
5959- ref: string,
6060- mimeType: string,
6161- size: number,
6262- dimensions: {width: number, height: number}
6363-}> {
6464- // Validate video size
6565- if (!validateVideo(buffer)) {
6666- throw new Error('Video validation failed');
6767- }
6868-6969- // Get video dimensions
7070- const dimensions = await getVideoDimensions(filePath);
7171-7272- // Return video metadata in Bluesky format
7373- return {
7474- ref: '', // This will be filled by the upload process with the CID
7575- mimeType: 'video/mp4',
7676- size: buffer.length,
7777- dimensions
7878- };
7979-}
8080-8181-/**
8282- * Creates the video embed structure for Bluesky post
8383- */
8484-export function createVideoEmbed(videoData: {
8585- ref: string,
8686- mimeType: string,
8787- size: number,
8888- dimensions: {width: number, height: number}
8989-}) {
9090- return {
9191- $type: "app.bsky.embed.video",
9292- video: {
9393- $type: "blob",
9494- ref: {
9595- $link: videoData.ref
9696- },
9797- mimeType: videoData.mimeType,
9898- size: videoData.size
9999- },
100100- aspectRatio: {
101101- width: videoData.dimensions.width,
102102- height: videoData.dimensions.height
103103- }
104104- };
105105-}
106106-107107-108108-export async function processVideoPost(
109109- filePath: string,
110110- buffer: Buffer,
111111- bluesky: BlueskyClient | null,
112112- simulate: boolean
113113-) {
114114- try {
115115- if (!buffer) {
116116- throw new Error("Video buffer is undefined");
117117- }
118118-119119- logger.debug({
120120- message: "Processing video",
121121- fileSize: buffer.length,
122122- filePath,
123123- });
124124-125125- // Prepare video metadata
126126- const videoData = await prepareVideoUpload(filePath, buffer);
127127-128128- // Upload video to get CID
129129- if (!simulate && bluesky) {
130130- const blob = await bluesky.uploadVideo(buffer);
131131- if (!blob?.ref?.$link) {
132132- throw new Error("Failed to get video upload reference");
133133- }
134134- videoData.ref = blob.ref.$link;
135135- }
136136-137137- // Create video embed structure
138138- const videoEmbed = createVideoEmbed(videoData);
139139-140140- return videoEmbed;
141141- } catch (error) {
142142- logger.error("Failed to process video:", error);
143143- throw error;
144144- }
145145-}
+2
src/video/README.ms
···11+# Video Utils
22+`video.ts` is for all video processing utils unrelated to the Bluesky protocol.
+205
src/video/video.ts
···11+import ffmpeg from 'fluent-ffmpeg';
22+import ffprobe from '@ffprobe-installer/ffprobe';
33+import { logger } from '../logger/logger'
44+import { BlueskyClient, VideoEmbed } from '../bluesky/bluesky';
55+import { BlobRef } from '@atproto/api';
66+77+// Configure ffmpeg to use ffprobe
88+ffmpeg.setFfprobePath(ffprobe.path);
99+1010+/**
1111+ * Validates video size is not greater than Blueskys max.
1212+ * @returns boolean
1313+ */
1414+export function validateVideo(buffer: Buffer): boolean {
1515+ const MAX_SIZE = 100 * 1024 * 1024; // 100MB
1616+ logger.debug(`Validating video size: ${Math.round(buffer.length / 1024 / 1024)}MB`);
1717+ if (buffer.length > MAX_SIZE) {
1818+ logger.warn(`Video file too large: ${Math.round(buffer.length / 1024 / 1024)}MB (max ${MAX_SIZE / 1024 / 1024}MB)`);
1919+ return false;
2020+ }
2121+ return true;
2222+}
2323+2424+/**
2525+ * Uses FFMpeg to resolve the video dimensions.
2626+ * @returns Promise<{width: number, height: number}>
2727+ */
2828+export async function getVideoDimensions(filePath: string): Promise<{width: number, height: number}> {
2929+ logger.debug(`Getting video dimensions for: ${filePath}`);
3030+ return new Promise((resolve, reject) => {
3131+ ffmpeg.ffprobe(filePath, (err: Error, metadata) => {
3232+ if (err) {
3333+ logger.error(`FFprobe error: ${err.message}`);
3434+ reject(err);
3535+ return;
3636+ }
3737+3838+ const videoStream = metadata.streams.find(s => s.codec_type === 'video');
3939+ if (!videoStream) {
4040+ logger.error('No video stream found in file');
4141+ reject(new Error('No video stream found'));
4242+ return;
4343+ }
4444+4545+ const dimensions = {
4646+ width: videoStream.width || 640,
4747+ height: videoStream.height || 640
4848+ };
4949+ logger.debug(`Video dimensions: ${dimensions.width}x${dimensions.height}`);
5050+ resolve(dimensions);
5151+ });
5252+ });
5353+}
5454+5555+export interface VideoUploadData {
5656+ ref: BlobRef | undefined;
5757+ mimeType: string;
5858+ size: number;
5959+ dimensions: {
6060+ width: number;
6161+ height: number;
6262+ };
6363+}
6464+6565+export class VideoUploadDataImpl implements VideoUploadData {
6666+ constructor(
6767+ public ref: BlobRef | undefined,
6868+ public mimeType: string,
6969+ public size: number,
7070+ public dimensions: {
7171+ width: number;
7272+ height: number;
7373+ }
7474+ ) {}
7575+7676+ static createDefault(buffer: Buffer): VideoUploadDataImpl {
7777+ return new VideoUploadDataImpl(
7878+ undefined, // empty ref to be filled later
7979+ 'video/mp4',
8080+ buffer.length,
8181+ { width: 640, height: 640 }
8282+ );
8383+ }
8484+}
8585+8686+// TODO not setting a blobref screams the wrong place blobref is for bluesky not video processing.
8787+export async function prepareVideoUpload(filePath: string, buffer: Buffer): Promise<VideoUploadData> {
8888+ if (!validateVideo(buffer)) {
8989+ throw new Error('Video validation failed');
9090+ }
9191+9292+ return VideoUploadDataImpl.createDefault(buffer);
9393+}
9494+9595+export interface VideoEmbedOutput {
9696+ $type: "app.bsky.embed.video";
9797+ video: {
9898+ $type: string;
9999+ ref: { $link: string };
100100+ mimeType: string;
101101+ size: number;
102102+ };
103103+ aspectRatio: {
104104+ width: number;
105105+ height: number;
106106+ };
107107+}
108108+109109+export class VideoEmbedOutputImpl implements VideoEmbedOutput {
110110+ readonly $type = "app.bsky.embed.video";
111111+ readonly video: {
112112+ $type: string;
113113+ ref: { $link: string };
114114+ mimeType: string;
115115+ size: number;
116116+ };
117117+ readonly aspectRatio: {
118118+ width: number;
119119+ height: number;
120120+ };
121121+122122+ constructor(
123123+ ref: string,
124124+ mimeType: string,
125125+ size: number,
126126+ dimensions: { width: number; height: number }
127127+ ) {
128128+ this.video = {
129129+ $type: "blob",
130130+ ref: { $link: ref },
131131+ mimeType,
132132+ size
133133+ };
134134+ this.aspectRatio = dimensions;
135135+ }
136136+}
137137+138138+/**
139139+ * Creates the video embed structure for Bluesky post
140140+ */
141141+export function createVideoEmbed(videoData: {
142142+ ref: string,
143143+ mimeType: string,
144144+ size: number,
145145+ dimensions: {width: number, height: number}
146146+}): VideoEmbedOutput {
147147+ return new VideoEmbedOutputImpl(
148148+ videoData.ref,
149149+ videoData.mimeType,
150150+ videoData.size,
151151+ videoData.dimensions
152152+ );
153153+}
154154+155155+/**
156156+ * Processes a video file for posting to Bluesky, including metadata preparation and upload
157157+ *
158158+ * @param filePath - The path to the video file being processed
159159+ * @param buffer - The video file contents as a Buffer
160160+ * @param bluesky - BlueskyClient instance for uploading, or null if not uploading
161161+ * @param simulate - If true, skips the actual upload to Bluesky
162162+ *
163163+ * @returns A video embed structure ready for posting to Bluesky
164164+ * @throws {Error} If video buffer is undefined or upload fails
165165+ */
166166+export async function processVideoPost(
167167+ filePath: string,
168168+ buffer: Buffer,
169169+ bluesky: BlueskyClient | null,
170170+ simulate: boolean
171171+) {
172172+ try {
173173+ if (!buffer) {
174174+ throw new Error("Video buffer is undefined");
175175+ }
176176+177177+ logger.debug({
178178+ message: "Processing video",
179179+ fileSize: buffer.length,
180180+ filePath,
181181+ });
182182+183183+ // Prepare video metadata
184184+ const videoData = await prepareVideoUpload(filePath, buffer);
185185+186186+ // Upload video to get CID
187187+ if (!simulate && bluesky) {
188188+189189+ // TODO isolate this logic and remove it only being placed into the media directory.
190190+ const blob = await bluesky.uploadVideo(buffer);
191191+ if (!blob?.ref) {
192192+ throw new Error("Failed to get video upload reference");
193193+ }
194194+ videoData.ref.$link = blob.ref.$link;
195195+ }
196196+197197+ // Create video embed structure
198198+ const videoEmbed = createVideoEmbed(videoData);
199199+200200+ return videoEmbed;
201201+ } catch (error) {
202202+ logger.error("Failed to process video:", error);
203203+ throw error;
204204+ }
205205+}