├── .editorconfig ├── LICENSE ├── README.md └── src ├── does-file-exist.mjs ├── download-video-to-tmp-directory.mjs ├── generate-thumbnails-from-video.mjs ├── generate-tmp-file-path.mjs └── index.mjs /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_size = 4 7 | indent_style = tabs 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = 0 13 | trim_trailing_whitespace = false 14 | 15 | [*.yml] 16 | indent_size = 2 17 | indent_style = spaces -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 marknorrapscm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Lambda Thumbnail Generation 2 | 3 | ### 🚀 What is this? 4 | 5 | This is the complete source code to accompany the [article here](https://www.norrapscm.com/posts/2021-02-08-generate-thumbnails-in-lambda-from-s3-with-ffmpeg/). 6 | 7 | ### ⭐ What does it do? 8 | 9 | It takes a video that was uploaded to an S3 bucket and generates *n* number of thumbnails from it. 10 | 11 | ### 💨 How do I use it? 12 | 13 | Create a Lambda and use the files in the `src/` folder. You need to read the article to see how it ties in the AWS infrastructure, but basically: 14 | 15 | * Create an S3 bucket 16 | * Create a trigger event to run a Lambda whenever an `.mp4` file is uploaded to that bucket 17 | * That event triggers the Lambda (created from this source code) which uses FFmpeg to generate *n* thumbnails 18 | * Those thumbnails are then uploaded to another S3 bucket 19 | 20 | ### 🎲 Why would I use this over AWS Transcoder? 21 | 22 | You wouldn't, necessarily. AWS Transcoder is good but very expensive compared to doing it yourself in Lambda. I have processed a few hundred short videos using the above source code / technique laid out in the article; the cost has been $0.00. AWS Transcoder costs around $0.45 per 60 minutes of video processed. 23 | 24 | --- 25 | 26 | ### Changelog: 27 | #### 13th February 2023: 28 | 29 | AWS has begun defaulting Lambdas to v18 of Node. The code has been updated to use v18, which includes: 30 | * Using ESM syntax rather than CJS (`export default` rather than `module.exports`) 31 | * Using the `.mjs` extension rather than `.js` 32 | * Assuming V3 of the AWS-SDK rather than V2 -------------------------------------------------------------------------------- /src/does-file-exist.mjs: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | export default (filePath) => { 4 | if (fs.existsSync(filePath)) { 5 | const stats = fs.statSync(filePath); 6 | const fileSizeInBytes = stats.size; 7 | 8 | if (fileSizeInBytes > 0) { 9 | return true; 10 | } else { 11 | console.error(`${filePath} exists but is 0 bytes in size`); 12 | return false; 13 | } 14 | } else { 15 | console.error(`${filePath} does not exist`); 16 | return false; 17 | } 18 | }; -------------------------------------------------------------------------------- /src/download-video-to-tmp-directory.mjs: -------------------------------------------------------------------------------- 1 | import { S3 } from "@aws-sdk/client-s3"; 2 | import fs from "fs"; 3 | import generateTmpFilePath from "./generate-tmp-file-path.mjs"; 4 | 5 | export default async (triggerBucketName, videoFileName) => { 6 | const downloadResult = await getVideoFromS3(triggerBucketName, videoFileName); 7 | const videoAsBuffer = downloadResult.Body; 8 | const tmpVideoFilePath = await saveFileToTmpDirectory(videoAsBuffer); 9 | 10 | return tmpVideoFilePath; 11 | } 12 | 13 | const getVideoFromS3 = async (triggerBucketName, fileName) => { 14 | const s3 = new S3(); 15 | const res = await s3.getObject({ 16 | Bucket: triggerBucketName, 17 | Key: fileName 18 | }); 19 | 20 | return res; 21 | }; 22 | 23 | const saveFileToTmpDirectory = async (fileAsBuffer) => { 24 | const tmpVideoPathTemplate = "/tmp/vid-{HASH}.mp4"; 25 | const tmpVideoFilePath = generateTmpFilePath(tmpVideoPathTemplate); 26 | await fs.promises.writeFile(tmpVideoFilePath, fileAsBuffer, "base64"); 27 | 28 | return tmpVideoFilePath; 29 | }; -------------------------------------------------------------------------------- /src/generate-thumbnails-from-video.mjs: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { S3 } from "@aws-sdk/client-s3"; 3 | import { spawnSync } from "child_process"; 4 | import doesFileExist from "./does-file-exist.mjs"; 5 | import generateTmpFilePath from "./generate-tmp-file-path.mjs"; 6 | 7 | const ffprobePath = "/opt/bin/ffprobe"; 8 | const ffmpegPath = "/opt/bin/ffmpeg"; 9 | 10 | const THUMBNAIL_TARGET_BUCKET = "demo-thumbnail-bucket"; 11 | 12 | export default async (tmpVideoPath, numberOfThumbnails, videoFileName) => { 13 | const randomTimes = generateRandomTimes(tmpVideoPath, numberOfThumbnails); 14 | 15 | for(const [index, randomTime] of Object.entries(randomTimes)) { 16 | const tmpThumbnailPath = await createImageFromVideo(tmpVideoPath, randomTime); 17 | 18 | if (doesFileExist(tmpThumbnailPath)) { 19 | const nameOfImageToCreate = generateNameOfImageToUpload(videoFileName, index); 20 | await uploadFileToS3(tmpThumbnailPath, nameOfImageToCreate); 21 | } 22 | } 23 | } 24 | 25 | const generateRandomTimes = (tmpVideoPath, numberOfTimesToGenerate) => { 26 | const timesInSeconds = []; 27 | const videoDuration = getVideoDuration(tmpVideoPath); 28 | 29 | for (let x = 0; x < numberOfTimesToGenerate; x++) { 30 | const randomNum = getRandomNumberNotInExistingList(timesInSeconds, videoDuration); 31 | 32 | if(randomNum >= 0) { 33 | timesInSeconds.push(randomNum); 34 | } 35 | } 36 | 37 | return timesInSeconds; 38 | }; 39 | 40 | const getRandomNumberNotInExistingList = (existingList, maxValueOfNumber) => { 41 | for (let attemptNumber = 0; attemptNumber < 3; attemptNumber++) { 42 | const randomNum = getRandomNumber(maxValueOfNumber); 43 | 44 | if (!existingList.includes(randomNum)) { 45 | return randomNum; 46 | } 47 | } 48 | 49 | return -1; 50 | } 51 | 52 | const getRandomNumber = (upperLimit) => { 53 | return Math.floor(Math.random() * upperLimit); 54 | }; 55 | 56 | const getVideoDuration = (tmpVideoPath) => { 57 | const ffprobe = spawnSync(ffprobePath, [ 58 | "-v", 59 | "error", 60 | "-show_entries", 61 | "format=duration", 62 | "-of", 63 | "default=nw=1:nk=1", 64 | tmpVideoPath 65 | ]); 66 | 67 | return Math.floor(ffprobe.stdout.toString()); 68 | }; 69 | 70 | const createImageFromVideo = (tmpVideoPath, targetSecond) => { 71 | const tmpThumbnailPath = generateThumbnailPath(targetSecond); 72 | const ffmpegParams = createFfmpegParams(tmpVideoPath, tmpThumbnailPath, targetSecond); 73 | spawnSync(ffmpegPath, ffmpegParams); 74 | 75 | return tmpThumbnailPath; 76 | }; 77 | 78 | const generateThumbnailPath = (targetSecond) => { 79 | const tmpThumbnailPathTemplate = "/tmp/thumbnail-{HASH}-{num}.jpg"; 80 | const uniqueThumbnailPath = generateTmpFilePath(tmpThumbnailPathTemplate); 81 | const thumbnailPathWithNumber = uniqueThumbnailPath.replace("{num}", targetSecond); 82 | 83 | return thumbnailPathWithNumber; 84 | }; 85 | 86 | const createFfmpegParams = (tmpVideoPath, tmpThumbnailPath, targetSecond) => { 87 | return [ 88 | "-ss", targetSecond, 89 | "-i", tmpVideoPath, 90 | "-vf", "thumbnail,scale=80:140", 91 | "-vframes", 1, 92 | tmpThumbnailPath 93 | ]; 94 | }; 95 | 96 | const generateNameOfImageToUpload = (videoFileName, i) => { 97 | const strippedExtension = videoFileName.replace(".mp4", ""); 98 | return `${strippedExtension}-${i}.jpg`; 99 | }; 100 | 101 | const uploadFileToS3 = async (tmpThumbnailPath, nameOfImageToCreate) => { 102 | const contents = fs.createReadStream(tmpThumbnailPath); 103 | const uploadParams = { 104 | Bucket: THUMBNAIL_TARGET_BUCKET, 105 | Key: nameOfImageToCreate, 106 | Body: contents, 107 | ContentType: "image/jpg" 108 | }; 109 | 110 | const s3 = new S3(); 111 | await s3.putObject(uploadParams)(); 112 | }; 113 | -------------------------------------------------------------------------------- /src/generate-tmp-file-path.mjs: -------------------------------------------------------------------------------- 1 | export default (filePathTemplate) => { 2 | const hash = getRandomString(10); 3 | const tmpFilePath = filePathTemplate.replace("{HASH}", hash); 4 | 5 | return tmpFilePath; 6 | } 7 | 8 | const getRandomString = (len) => { 9 | const charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; 10 | let result = ""; 11 | 12 | for (let i = len; i > 0; --i) { 13 | result += charset[Math.floor(Math.random() * charset.length)]; 14 | } 15 | 16 | return result; 17 | } -------------------------------------------------------------------------------- /src/index.mjs: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import doesFileExist from "./does-file-exist.mjs"; 4 | import downloadVideoToTmpDirectory from "./download-video-to-tmp-directory.mjs"; 5 | import generateThumbnailsFromVideo from "./generate-thumbnails-from-video.mjs"; 6 | 7 | const THUMBNAILS_TO_CREATE = 2; 8 | 9 | export const handler = async (event) => { 10 | await wipeTmpDirectory(); 11 | const { videoFileName, triggerBucketName } = extractParams(event); 12 | const tmpVideoPath = await downloadVideoToTmpDirectory(triggerBucketName, videoFileName); 13 | 14 | if (doesFileExist(tmpVideoPath)) { 15 | await generateThumbnailsFromVideo(tmpVideoPath, THUMBNAILS_TO_CREATE, videoFileName); 16 | } 17 | }; 18 | 19 | const extractParams = event => { 20 | const videoFileName = decodeURIComponent(event.Records[0].s3.object.key).replace(/\+/g, " "); 21 | const triggerBucketName = event.Records[0].s3.bucket.name; 22 | 23 | return { videoFileName, triggerBucketName }; 24 | }; 25 | 26 | const wipeTmpDirectory = async () => { 27 | const files = await fs.promises.readdir("/tmp/"); 28 | const filePaths = files.map(file => path.join("/tmp/", file)); 29 | await Promise.all(filePaths.map(file => fs.promises.unlink(file))); 30 | } --------------------------------------------------------------------------------