Import Instagram archive to a Bluesky account
9
fork

Configure Feed

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

Fixed video tests eliminating all bluesky from the video utility layer.

+79 -213
+77 -110
src/video/video.test.ts
··· 1 - import { validateVideo, getVideoDimensions, processVideoPost } from './video.js'; 2 - import path from 'path'; 3 - import { BlueskyClient } from '@bluesky/bluesky.js'; 1 + import { validateVideo, getVideoDimensions } from './video'; 2 + import ffmpeg from 'fluent-ffmpeg'; 4 3 5 - describe('Video Processing', () => { 6 - const testVideoPath = path.join(__dirname, '../transfer/test_videos/AQM8KYlOYHTF5GlP43eMroHUpmnFHJh5CnCJUdRUeqWxG4tNX7D43eM77F152vfi4znTzgkFTTzzM4nHa_v8ugmP4WPRJtjKPZX5pko_17845940218109367.mp4'); 7 - 8 - describe('validateVideo', () => { 9 - test('should reject videos larger than 100MB', () => { 10 - const largeBuffer = Buffer.alloc(101 * 1024 * 1024); // 101MB 11 - expect(validateVideo(largeBuffer)).toBe(false); 12 - }); 4 + // Mock ffmpeg 5 + jest.mock('fluent-ffmpeg', () => { 6 + const mockFfprobe = jest.fn(); 7 + const mockFfmpeg: jest.MockedFunction<typeof ffmpeg> & { 8 + setFfprobePath: jest.Mock; 9 + ffprobe: jest.Mock; 10 + } = Object.assign( 11 + jest.fn(() => ({ 12 + ffprobe: mockFfprobe 13 + })), 14 + { 15 + setFfprobePath: jest.fn(), 16 + ffprobe: mockFfprobe 17 + } 18 + ); 19 + return mockFfmpeg; 20 + }); 13 21 14 - test('should accept videos smaller than 100MB', () => { 15 - const smallBuffer = Buffer.alloc(50 * 1024 * 1024); // 50MB 16 - expect(validateVideo(smallBuffer)).toBe(true); 17 - }); 18 - }); 19 - 20 - describe('getVideoDimensions', () => { 21 - test('should get correct dimensions from test video', async () => { 22 - const dimensions = await getVideoDimensions(testVideoPath); 23 - expect(dimensions).toEqual({ 24 - width: 640, 25 - height: 640 26 - }); 27 - }); 22 + // Mock @ffprobe-installer/ffprobe 23 + jest.mock('@ffprobe-installer/ffprobe', () => ({ 24 + path: '/mock/ffprobe/path' 25 + })); 28 26 29 - test('should throw error for non-existent file', async () => { 30 - await expect( 31 - getVideoDimensions('nonexistent/video.mp4') 32 - ).rejects.toThrow(); 27 + describe('video utilities', () => { 28 + describe('validateVideo', () => { 29 + test('should return true for videos under 100MB', () => { 30 + const buffer = Buffer.alloc(50 * 1024 * 1024); // 50MB 31 + expect(validateVideo(buffer)).toBe(true); 33 32 }); 34 33 35 - test('should throw error for invalid video file', async () => { 36 - const invalidFilePath = path.join(__dirname, 'video.test.ts'); // Using this test file as invalid video 37 - await expect( 38 - getVideoDimensions(invalidFilePath) 39 - ).rejects.toThrow(); 34 + test('should return false for videos over 100MB', () => { 35 + const buffer = Buffer.alloc(150 * 1024 * 1024); // 150MB 36 + expect(validateVideo(buffer)).toBe(false); 40 37 }); 41 38 }); 42 39 43 - describe('processVideoPost', () => { 44 - const mockVideoBuffer = Buffer.from('test video content'); 45 - 46 - // Mock BlueskyClient 47 - const mockBluesky = { 48 - uploadVideo: jest.fn().mockResolvedValue({ 49 - ref: { 50 - $link: 'test-cid' 51 - } 52 - }) 53 - } as unknown as BlueskyClient; 54 - 40 + describe('getVideoDimensions', () => { 55 41 beforeEach(() => { 56 42 jest.clearAllMocks(); 57 43 }); 58 44 59 - test('should process video successfully with upload', async () => { 60 - const result = await processVideoPost( 61 - testVideoPath, 62 - mockVideoBuffer, 63 - mockBluesky, 64 - false // not simulating 65 - ); 45 + test('should return video dimensions when ffprobe succeeds', async () => { 46 + const mockMetadata = { 47 + streams: [ 48 + { 49 + codec_type: 'video', 50 + width: 1920, 51 + height: 1080 52 + } 53 + ] 54 + }; 66 55 67 - expect(mockBluesky.uploadVideo).toHaveBeenCalledWith(mockVideoBuffer); 68 - expect(result).toMatchObject({ 69 - $type: 'app.bsky.embed.video', 70 - video: { 71 - $type: 'blob', 72 - ref: { 73 - $link: 'test-cid' 74 - } 75 - }, 76 - aspectRatio: { 77 - width: 640, 78 - height: 640 79 - } 56 + (ffmpeg.ffprobe as jest.Mock).mockImplementation((path, callback) => { 57 + callback(null, mockMetadata); 80 58 }); 59 + 60 + const dimensions = await getVideoDimensions('test.mp4'); 61 + expect(dimensions).toEqual({ width: 1920, height: 1080 }); 81 62 }); 82 63 83 - test('should process video in simulation mode without upload', async () => { 84 - const result = await processVideoPost( 85 - testVideoPath, 86 - mockVideoBuffer, 87 - mockBluesky, 88 - true // simulating 89 - ); 64 + test('should use default dimensions when width/height not found', async () => { 65 + const mockMetadata = { 66 + streams: [ 67 + { 68 + codec_type: 'video' 69 + } 70 + ] 71 + }; 90 72 91 - expect(mockBluesky.uploadVideo).not.toHaveBeenCalled(); 92 - expect(result).toMatchObject({ 93 - $type: 'app.bsky.embed.video', 94 - video: { 95 - $type: 'blob', 96 - ref: { 97 - $link: '' 98 - } 99 - } 73 + (ffmpeg.ffprobe as jest.Mock).mockImplementation((path, callback) => { 74 + callback(null, mockMetadata); 100 75 }); 76 + 77 + const dimensions = await getVideoDimensions('test.mp4'); 78 + expect(dimensions).toEqual({ width: 640, height: 640 }); 101 79 }); 102 80 103 - test('should process video without Bluesky client', async () => { 104 - const result = await processVideoPost( 105 - testVideoPath, 106 - mockVideoBuffer, 107 - null, 108 - false 109 - ); 110 - 111 - expect(result).toMatchObject({ 112 - $type: 'app.bsky.embed.video', 113 - video: { 114 - $type: 'blob', 115 - ref: { 116 - $link: '' 81 + test('should reject when no video stream found', async () => { 82 + const mockMetadata = { 83 + streams: [ 84 + { 85 + codec_type: 'audio' 117 86 } 118 - } 87 + ] 88 + }; 89 + 90 + (ffmpeg.ffprobe as jest.Mock).mockImplementation((path, callback) => { 91 + callback(null, mockMetadata); 119 92 }); 120 - }); 121 93 122 - test('should throw error for undefined buffer', async () => { 123 - await expect( 124 - processVideoPost(testVideoPath, undefined as unknown as Buffer, mockBluesky, false) 125 - ).rejects.toThrow('Video buffer is undefined'); 94 + await expect(getVideoDimensions('test.mp4')).rejects.toThrow('No video stream found'); 126 95 }); 127 96 128 - test('should throw error when upload fails', async () => { 129 - const failingBluesky = { 130 - uploadVideo: jest.fn().mockResolvedValue(null) 131 - } as unknown as BlueskyClient; 97 + test('should reject when ffprobe fails', async () => { 98 + (ffmpeg.ffprobe as jest.Mock).mockImplementation((path, callback) => { 99 + callback(new Error('FFprobe failed'), null); 100 + }); 132 101 133 - await expect( 134 - processVideoPost(testVideoPath, mockVideoBuffer, failingBluesky, false) 135 - ).rejects.toThrow('Failed to get video upload reference'); 102 + await expect(getVideoDimensions('test.mp4')).rejects.toThrow('FFprobe failed'); 136 103 }); 137 104 }); 138 - }); 105 + });
+2 -103
src/video/video.ts
··· 1 1 import ffmpeg from 'fluent-ffmpeg'; 2 2 import ffprobe from '@ffprobe-installer/ffprobe'; 3 - import { logger } from '@logger/logger.js' 4 - import { BlobRef } from '@atproto/api'; 3 + import { logger } from '../logger/logger' 5 4 6 5 // Configure ffmpeg to use ffprobe 7 6 ffmpeg.setFfprobePath(ffprobe.path); 8 7 9 8 /** 10 - * Validates video size is not greater than Blueskys max. 9 + * Validates video size is not greater than max size. 11 10 * @returns boolean 12 11 */ 13 12 export function validateVideo(buffer: Buffer): boolean { ··· 50 49 }); 51 50 }); 52 51 } 53 - 54 - export interface VideoUploadData { 55 - ref: BlobRef | undefined; 56 - mimeType: string; 57 - size: number; 58 - dimensions: { 59 - width: number; 60 - height: number; 61 - }; 62 - } 63 - 64 - export class VideoUploadDataImpl implements VideoUploadData { 65 - constructor( 66 - public ref: BlobRef | undefined, 67 - public mimeType: string, 68 - public size: number, 69 - public dimensions: { 70 - width: number; 71 - height: number; 72 - } 73 - ) {} 74 - 75 - static createDefault(buffer: Buffer): VideoUploadDataImpl { 76 - return new VideoUploadDataImpl( 77 - undefined, // empty ref to be filled later 78 - 'video/mp4', 79 - buffer.length, 80 - { width: 640, height: 640 } 81 - ); 82 - } 83 - } 84 - 85 - export interface VideoEmbedOutput { 86 - $type: "app.bsky.embed.video"; 87 - video: BlobRef; 88 - aspectRatio: { 89 - width: number; 90 - height: number; 91 - }; 92 - } 93 - 94 - export class VideoEmbedOutputImpl implements VideoEmbedOutput { 95 - readonly $type = "app.bsky.embed.video"; 96 - readonly video: BlobRef; 97 - readonly aspectRatio: { 98 - width: number; 99 - height: number; 100 - }; 101 - 102 - constructor( 103 - ref: BlobRef, 104 - mimeType: string, 105 - size: number, 106 - dimensions: { width: number; height: number } 107 - ) { 108 - this.video = ref; 109 - this.aspectRatio = dimensions; 110 - } 111 - } 112 - 113 - /** 114 - * 115 - * // Handle image uploads if present 116 - if (Array.isArray(embeddedMedia) && AppBskyEmbedImages.isImage(embeddedMedia[0])) { 117 - const imagesMedia: ImageEmbed[] = embeddedMedia; 118 - const uploadedImages = await Promise.all( 119 - imagesMedia.map(async (media) => { 120 - const blob = await this.uploadImage( 121 - media.image, 122 - media.mimeType 123 - ); 124 - return new ImageEmbedImpl( 125 - media.alt, 126 - blob, 127 - media.mimeType, 128 - media.uploadData 129 - ); 130 - }) 131 - ); 132 - 133 - embeddedMedia = new ImagesEmbedImpl(uploadedImages); 134 - } else if (AppBskyEmbedVideo.isMain(embeddedMedia)) { 135 - // Upload video first 136 - const videoBlobRef = await this.uploadVideo( 137 - embeddedMedia.buffer, 138 - embeddedMedia.mimeType 139 - ); 140 - // Now transform the embed 141 - embeddedMedia = new VideoEmbedImpl( 142 - "", 143 - embeddedMedia.buffer, 144 - embeddedMedia.mimeType, 145 - embeddedMedia.size, 146 - videoBlobRef, 147 - embeddedMedia.aspectRatio, 148 - embeddedMedia.captions 149 - ); 150 - } 151 - * 152 - */