mirror of
https://github.com/flutter/holobooth.git
synced 2025-08-06 06:44:56 +08:00
feat: adds gif download option in addition to video (#278)
* feat: adding gif recorder * feat: adding gif convertion * lint * pr follow up * PR follow up
This commit is contained in:
17
functions/package-lock.json
generated
17
functions/package-lock.json
generated
@ -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",
|
||||
|
@ -65,8 +65,11 @@ function setUpFileMakePublicFunction(returnValue) {
|
||||
function setUpFfmpeg(currentEvent: string) {
|
||||
ffmpeg = mockDeep<ffmpeg>({
|
||||
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';
|
||||
|
@ -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<string> {
|
||||
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
|
||||
|
@ -30,9 +30,14 @@ class ConvertBloc extends Bloc<ConvertEvent, ConvertState> {
|
||||
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());
|
||||
|
@ -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<Object> get props => [videoPath];
|
||||
List<Object> get props => [videoPath, gifPath];
|
||||
}
|
||||
|
||||
class ConvertError extends ConvertState {}
|
||||
|
@ -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<String, dynamic> 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<String> convertFrames(List<Uint8List> frames) async {
|
||||
Future<ConvertResponse> convertFrames(List<Uint8List> 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<String, dynamic>;
|
||||
return ConvertResponse.fromJson(json);
|
||||
} else {
|
||||
throw const ConvertException('Failed to convert frames');
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
@ -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',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
Reference in New Issue
Block a user