diff --git a/functions/package-lock.json b/functions/package-lock.json index 693bff7e..918ca289 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -27,6 +27,7 @@ "firebase-mock": "^2.3.2", "jest": "^29.3.1", "jest-mock-extended": "^3.0.1", + "jest-when": "^3.5.2", "mocha": "^10.2.0", "prettier": "^2.8.1", "prettier-eslint": "^12.0.0", @@ -5722,6 +5723,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-when": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/jest-when/-/jest-when-3.5.2.tgz", + "integrity": "sha512-4rDvnhaWh08RcPsoEVXgxRnUIE9wVIbZtGqZ5x2Wm9Ziz9aQs89PipQFmOK0ycbEhVAgiV3MUeTXp3Ar4s2FcQ==", + "dev": true, + "peerDependencies": { + "jest": ">= 25" + } + }, "node_modules/jest-worker": { "version": "29.3.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.3.1.tgz", @@ -13296,6 +13306,13 @@ "string-length": "^4.0.1" } }, + "jest-when": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/jest-when/-/jest-when-3.5.2.tgz", + "integrity": "sha512-4rDvnhaWh08RcPsoEVXgxRnUIE9wVIbZtGqZ5x2Wm9Ziz9aQs89PipQFmOK0ycbEhVAgiV3MUeTXp3Ar4s2FcQ==", + "dev": true, + "requires": {} + }, "jest-worker": { "version": "29.3.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.3.1.tgz", diff --git a/functions/src/convert/index.spec.ts b/functions/src/convert/index.spec.ts index 7609076a..f7b4b886 100644 --- a/functions/src/convert/index.spec.ts +++ b/functions/src/convert/index.spec.ts @@ -65,8 +65,11 @@ function setUpFileMakePublicFunction(returnValue) { function setUpFfmpeg(currentEvent: string) { ffmpeg = mockDeep({ addInput: jest.fn().mockReturnThis(), + addOutput: jest.fn().mockReturnThis(), addOptions: jest.fn().mockReturnThis(), inputFPS: jest.fn().mockReturnThis(), + videoFilters: jest.fn().mockReturnThis(), + run: jest.fn().mockReturnThis(), mergeToFile: jest.fn().mockReturnThis(), on: jest.fn((event, handler) => { if (currentEvent == event && event === 'error') { @@ -135,8 +138,11 @@ jest.mock('busboy', () => () => { jest.mock('fluent-ffmpeg', () => () => { return { addInput: jest.fn().mockReturnThis(), + addOutput: jest.fn().mockReturnThis(), + run: jest.fn().mockReturnThis(), addOptions: jest.fn().mockReturnThis(), inputFPS: jest.fn().mockReturnThis(), + videoFilters: jest.fn().mockReturnThis(), mergeToFile: jest.fn().mockReturnThis(), on: jest.fn((event, handler) => { if (event === 'end') { @@ -201,9 +207,10 @@ describe('convert', () => { await convert.convert(mockRequest, mockResponse); expect(mockResponse.status).toHaveBeenCalledWith(200); - expect(mockResponse.send).toHaveBeenCalledWith( - 'https://storage.googleapis.com/test-bucket/test-file' - ); + expect(mockResponse.send).toHaveBeenCalledWith({ + video_url: 'https://storage.googleapis.com/test-bucket/test-file', + gif_url: 'https://storage.googleapis.com/test-bucket/test-file', + }); }); it('returns status 500 on error', async () => { @@ -242,9 +249,9 @@ describe('convertImages', () => { setUpMockReadStream('finish'); setUpFileMakePublicFunction(true); - const { status, url } = await convert.convertImages(mockRequest); + const { status, videoUrl } = await convert.convertImages(mockRequest); expect(status).toEqual(200); - expect(url).toEqual('https://storage.googleapis.com/test-bucket/test-file'); + expect(videoUrl).toEqual('https://storage.googleapis.com/test-bucket/test-file'); }); }); @@ -342,6 +349,24 @@ describe('convertToVideo', () => { }); }); +describe('convertVideoToGif', () => { + it('returns path for the file', async () => { + setUpFfmpeg('end'); + + await expect( + convert.convertVideoToGif(ffmpeg, `${tempDir}/video.mp4`, tempDir) + ).resolves.toBe(`${tempDir}/video.gif`); + }); + + it('throws error when unable to convert video.', async () => { + setUpFfmpeg('error'); + + await expect( + convert.convertVideoToGif(ffmpeg, `${tempDir}/video.mp4`, tempDir) + ).rejects.toThrow(); + }); +}); + describe('uploadFile', () => { const videoName = 'test-video-name'; const videoPath = 'test-path'; diff --git a/functions/src/convert/index.ts b/functions/src/convert/index.ts index 450222c9..0d450dbc 100644 --- a/functions/src/convert/index.ts +++ b/functions/src/convert/index.ts @@ -17,10 +17,13 @@ export const errorMessage = 'Something went wrong'; */ export const convert = functions.https.onRequest(async (req, res) => { try { - const { status, url } = await convertImages(req); + const { status, videoUrl, gifUrl } = await convertImages(req); res.set('Access-Control-Allow-Origin', '*'); - res.status(status).send(url); + res.status(status).send({ + video_url: videoUrl, + gif_url: gifUrl, + }); } catch (error) { functions.logger.error(error); res.status(500).send(errorMessage); @@ -29,7 +32,11 @@ export const convert = functions.https.onRequest(async (req, res) => { export async function convertImages( req: functions.https.Request, -): Promise<{ status: number; url: string }> { +): Promise<{ + status: number; + videoUrl: string, + gifUrl: string, +}> { let tempDir: string | null = null; try { @@ -48,13 +55,14 @@ export async function convertImages( tempDir = await createTempDirectory(userId); const busboy = _busboy({ headers: req.headers }); const frames = await readFramesFromRequest(busboy, req, tempDir); - const videoPath = await convertToVideo(ffmpeg(), frames, tempDir); - const url = await uploadFile(userId + '.mp4', videoPath); - return { - status: 200, - url: url, - }; + const videoPath = await convertToVideo(ffmpeg(), frames, tempDir); + const gifPath = await convertVideoToGif(ffmpeg(), videoPath, tempDir); + + const videoUrl = await uploadFile(userId + '.mp4', videoPath); + const gifUrl = await uploadFile(userId + '.gif', gifPath); + + return { status: 200, videoUrl, gifUrl }; } catch (error) { functions.logger.error(error); throw error; @@ -149,6 +157,28 @@ export async function convertToVideo( }); } +export async function convertVideoToGif( + ffmpeg: ffmpeg, + videoPath: string, + folder: string +): Promise { + const gifPath = `${folder}/video.gif`; + return new Promise((resolve, reject) => { + ffmpeg + .addInput(videoPath) + .videoFilters('fps=10,split [o1] [o2];[o1] palettegen [p]; [o2] fifo [o3];[o3] [p] paletteuse') + .addOutput(gifPath) + .on('end', () => { + resolve(gifPath); + }) + .on('error', function(error) { + functions.logger.error(error); + return reject( error); + }) + .run(); + }); +} + export function uploadFile( videoName: string, path: string diff --git a/lib/share/bloc/convert_bloc.dart b/lib/share/bloc/convert_bloc.dart index 41501594..6ef5bb6e 100644 --- a/lib/share/bloc/convert_bloc.dart +++ b/lib/share/bloc/convert_bloc.dart @@ -30,9 +30,14 @@ class ConvertBloc extends Bloc { for (final frame in event.frames) { frames.add(frame.image.buffer.asUint8List()); } - final videoPath = await _convertRepository.convertFrames(frames); + final result = await _convertRepository.convertFrames(frames); - emit(ConvertSuccess(videoPath)); + emit( + ConvertSuccess( + videoPath: result.videoUrl, + gifPath: result.gifUrl, + ), + ); } catch (error, stackTrace) { addError(error, stackTrace); emit(ConvertError()); diff --git a/lib/share/bloc/convert_state.dart b/lib/share/bloc/convert_state.dart index 2c0af442..15a63d9d 100644 --- a/lib/share/bloc/convert_state.dart +++ b/lib/share/bloc/convert_state.dart @@ -12,12 +12,16 @@ class ConvertInitial extends ConvertState {} class ConvertLoading extends ConvertState {} class ConvertSuccess extends ConvertState { - const ConvertSuccess(this.videoPath); + const ConvertSuccess({ + required this.videoPath, + required this.gifPath, + }); final String videoPath; + final String gifPath; @override - List get props => [videoPath]; + List get props => [videoPath, gifPath]; } class ConvertError extends ConvertState {} diff --git a/packages/convert_repository/lib/src/convert_repository.dart b/packages/convert_repository/lib/src/convert_repository.dart index 8edc2112..f24fceed 100644 --- a/packages/convert_repository/lib/src/convert_repository.dart +++ b/packages/convert_repository/lib/src/convert_repository.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:typed_data'; import 'package:http/http.dart'; @@ -15,6 +16,31 @@ class ConvertException implements Exception { String toString() => message; } +/// {@template convert_response} +/// Response data for the convert operation. +/// {@endtemplate} +class ConvertResponse { + /// {@macro convert_response} + const ConvertResponse({ + required this.videoUrl, + required this.gifUrl, + }); + + /// {@macro convert_response} + factory ConvertResponse.fromJson(Map json) { + return ConvertResponse( + videoUrl: json['video_url'] as String, + gifUrl: json['gif_url'] as String, + ); + } + + /// Url to download the video. + final String videoUrl; + + /// Url to download the gif. + final String gifUrl; +} + /// {@template convert_repository} /// Repository for converting frames in video. /// {@endtemplate} @@ -33,7 +59,7 @@ class ConvertRepository { /// Converts a list of images to video using firebase functions. /// On success, returns the video path from the cloud storage. /// On error it throws a [ConvertException]. - Future convertFrames(List frames) async { + Future convertFrames(List frames) async { if (frames.isEmpty) { throw const ConvertException('No frames to convert'); } @@ -51,7 +77,9 @@ class ConvertRepository { final response = await multipartRequest.send(); if (response.statusCode == 200) { - return response.stream.bytesToString(); + final rawData = await response.stream.bytesToString(); + final json = jsonDecode(rawData) as Map; + return ConvertResponse.fromJson(json); } else { throw const ConvertException('Failed to convert frames'); } diff --git a/packages/convert_repository/test/src/convert_repository_test.dart b/packages/convert_repository/test/src/convert_repository_test.dart index 1d238657..c55b78e5 100644 --- a/packages/convert_repository/test/src/convert_repository_test.dart +++ b/packages/convert_repository/test/src/convert_repository_test.dart @@ -54,12 +54,15 @@ void main() { test('return value on success', () async { when(() => streamedResponse.statusCode).thenReturn(200); when(() => streamedResponse.stream).thenAnswer( - (_) => ByteStream.fromBytes([112, 97, 116, 104]), + (_) => ByteStream.fromBytes( + '{"video_url": "video", "gif_url": "gif"}'.codeUnits, + ), ); - final path = await convertRepository.convertFrames([Uint8List(0)]); + final response = await convertRepository.convertFrames([Uint8List(0)]); - expect(path, equals('path')); + expect(response.videoUrl, equals('video')); + expect(response.gifUrl, equals('gif')); }); test('throws on status code different than 200', () async { diff --git a/test/share/bloc/convert_bloc_test.dart b/test/share/bloc/convert_bloc_test.dart index efab69ad..f2affecf 100644 --- a/test/share/bloc/convert_bloc_test.dart +++ b/test/share/bloc/convert_bloc_test.dart @@ -18,8 +18,12 @@ void main() { 'return video path for request', setUp: () { convertRepository = _MockConvertRepository(); - when(() => convertRepository.convertFrames(any())) - .thenAnswer((_) async => 'test-video-path'); + when(() => convertRepository.convertFrames(any())).thenAnswer( + (_) async => ConvertResponse( + videoUrl: 'test-video-path', + gifUrl: 'test-gif-path', + ), + ); }, build: () => ConvertBloc(convertRepository: convertRepository), act: (bloc) => bloc.add( @@ -31,7 +35,10 @@ void main() { ), expect: () => [ ConvertLoading(), - ConvertSuccess('test-video-path'), + ConvertSuccess( + videoPath: 'test-video-path', + gifPath: 'test-gif-path', + ), ], ); diff --git a/test/share/bloc/convert_state_test.dart b/test/share/bloc/convert_state_test.dart index ebe0e9c2..68f43eff 100644 --- a/test/share/bloc/convert_state_test.dart +++ b/test/share/bloc/convert_state_test.dart @@ -21,9 +21,46 @@ void main() { group('ConvertSuccess', () { test('support value equality', () { - final instanceA = ConvertSuccess('not-important'); - final instanceB = ConvertSuccess('not-important'); - expect(instanceA, equals(instanceB)); + expect( + ConvertSuccess( + videoPath: 'not-important', + gifPath: 'not-important', + ), + equals( + ConvertSuccess( + videoPath: 'not-important', + gifPath: 'not-important', + ), + ), + ); + expect( + ConvertSuccess( + videoPath: 'not-important', + gifPath: 'not-important', + ), + isNot( + equals( + ConvertSuccess( + videoPath: 'important', + gifPath: 'not-important', + ), + ), + ), + ); + expect( + ConvertSuccess( + videoPath: 'not-important', + gifPath: 'not-important', + ), + isNot( + equals( + ConvertSuccess( + videoPath: 'not-important', + gifPath: 'important', + ), + ), + ), + ); }); });