├── .gitignore
├── src
├── routes
│ ├── index.ts
│ ├── watch
│ │ └── index.ts
│ └── transcode
│ │ └── index.ts
├── controllers
│ ├── watchVideo.ts
│ ├── index.ts
│ └── transcodeVideo.ts
├── index.ts
├── utils
│ └── HttpError.ts
├── middlewares
│ ├── errorHandler.ts
│ └── fileUploader.ts
└── lib
│ └── app.ts
├── nodemon.json
├── index.html
├── CHANGELOG.md
├── tsconfig.json
├── package.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | **/*node_modules
2 | **/*dist
3 | **/*.DS_Store
4 | **/*videos/*
--------------------------------------------------------------------------------
/src/routes/index.ts:
--------------------------------------------------------------------------------
1 | export { default as TranscodeRoute } from "./transcode";
2 | export { default as VideoRoute } from "./watch";
3 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": ["./src"],
3 | "ext": "ts,json",
4 | "ignore": ["src/**/*.spec.ts", "./dist", "node_modules", "./src/videos/**/*"],
5 | "exec": "ts-node ./src/index.ts"
6 | }
7 |
--------------------------------------------------------------------------------
/src/controllers/watchVideo.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from "express";
2 |
3 | export default function watchVideo(
4 | req: Request,
5 | res: Response,
6 | next: NextFunction
7 | ) {}
8 |
--------------------------------------------------------------------------------
/src/routes/watch/index.ts:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 | import { watchVideo } from "../../controllers";
3 |
4 | const router = Router();
5 |
6 | router.get("/", watchVideo);
7 |
8 | export default router;
9 |
--------------------------------------------------------------------------------
/src/controllers/index.ts:
--------------------------------------------------------------------------------
1 | export { default as transcodeVideo } from "./transcodeVideo";
2 | export { default as watchVideo } from "./watchVideo";
3 |
4 | export * from "./transcodeVideo";
5 | export * from "./watchVideo";
6 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | require("regenerator-runtime");
2 |
3 | import app from "./lib/app";
4 |
5 | const PORT = process.env.PORT || 4000;
6 |
7 | const api = app.listen(PORT, () =>
8 | console.info(`Transcoder running on port ${PORT}`)
9 | );
10 |
11 | export default api;
12 |
--------------------------------------------------------------------------------
/src/utils/HttpError.ts:
--------------------------------------------------------------------------------
1 | export default class HttpError extends Error {
2 | message: string;
3 | statusCode: number;
4 | constructor(message: string, statusCode: number) {
5 | super();
6 | this.message = message;
7 | this.statusCode = statusCode;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/middlewares/errorHandler.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from "express";
2 | import HttpError from "../utils/HttpError";
3 |
4 | export default async function errorHandler(
5 | error: HttpError,
6 | req: Request,
7 | res: Response,
8 | next: NextFunction
9 | ) {
10 | return res.status(error.statusCode).json(error.message);
11 | }
12 |
--------------------------------------------------------------------------------
/src/routes/transcode/index.ts:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 | import { transcodeVideo } from "../../controllers";
3 | import fileUploader from "../../middlewares/fileUploader";
4 |
5 | const router = Router();
6 |
7 | // router.get("/new/:name", transcodeVideo);
8 | router.post("/new", fileUploader.single("file"), transcodeVideo);
9 |
10 | export default router;
11 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Document
7 |
8 |
9 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/lib/app.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import cors from "cors";
3 |
4 | import { TranscodeRoute, VideoRoute } from "../routes";
5 | import errorHandler from "../middlewares/errorHandler";
6 |
7 | const app = express();
8 |
9 | app.use(cors());
10 | app.use(express.json());
11 | app.use(express.urlencoded({ extended: false }));
12 |
13 | app.use("/transcode", TranscodeRoute);
14 | app.use("/watch", VideoRoute);
15 |
16 | app.use(errorHandler);
17 |
18 | export default app;
19 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Version 2.0.0
2 |
3 | - This version allows users to upload videos that they wish to transcode to the service using the `POST /transcode/new` endpoint.
4 | - Once transcoding is completed, uploaded file is removed from the local `fs`.
5 | - Since this project will be running locally, users can adjust the `src` path in the `./index.html` file to right `.m3u8` frile from the `dist` folder (generated by running `npx ts-build`)
6 |
7 | # Version 1.0.0
8 |
9 | - Transcode videos stored on your local fs and generate corresponding `.hls` and `.ts` chunk files
10 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES5",
4 | "module": "commonjs",
5 | "rootDir": ".",
6 | "moduleResolution": "node",
7 | "typeRoots": ["./src/types", "./node_modules/@types"],
8 | "resolveJsonModule": true,
9 | "declaration": true,
10 | "outDir": "./dist",
11 | "removeComments": true,
12 | "declarationDir": "./dist/types",
13 | "isolatedModules": true,
14 | "allowSyntheticDefaultImports": true,
15 | "esModuleInterop": true,
16 | "forceConsistentCasingInFileNames": true,
17 | "strict": true,
18 | "noImplicitAny": true,
19 | "strictNullChecks": true,
20 | "alwaysStrict": true,
21 | "skipDefaultLibCheck": true,
22 | "skipLibCheck": true
23 | },
24 | "exclude": ["node_modules", "./dist", "./jest*", "./src/videos"]
25 | }
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "video-transcoder",
3 | "version": "2.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "NODE_ENV=development nodemon ./src/index.ts",
8 | "ts-build": "npx tsc",
9 | "ts-check": "npx tsc --emitDeclarationOnly"
10 | },
11 | "keywords": [
12 | "node",
13 | "express",
14 | "ffmpeg",
15 | "fluent-ffmpeg"
16 | ],
17 | "author": "Aakash Jha ",
18 | "license": "ISC",
19 | "devDependencies": {
20 | "@types/cors": "^2.8.14",
21 | "@types/express": "^4.17.17",
22 | "@types/fluent-ffmpeg": "^2.1.21",
23 | "@types/multer": "^1.4.7",
24 | "@types/node": "^20.6.0",
25 | "nodemon": "^3.0.1",
26 | "ts-node": "^10.9.1",
27 | "typescript": "^5.2.2"
28 | },
29 | "dependencies": {
30 | "@ffmpeg-installer/ffmpeg": "^1.1.0",
31 | "@ffprobe-installer/ffprobe": "^2.1.2",
32 | "cors": "^2.8.5",
33 | "express": "^4.18.2",
34 | "ffmpeg": "^0.0.4",
35 | "ffmpeg-static": "^5.2.0",
36 | "fluent-ffmpeg": "^2.1.2",
37 | "multer": "^1.4.5-lts.1",
38 | "regenerator-runtime": "^0.14.0"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/middlewares/fileUploader.ts:
--------------------------------------------------------------------------------
1 | import { Request } from "express";
2 | import multer, { FileFilterCallback, Multer } from "multer";
3 | import path from "path";
4 | import { existsSync, mkdirSync } from "fs";
5 |
6 | import HttpError from "../utils/HttpError";
7 |
8 | function getUploadDirectoryPath(filename: string) {
9 | // return path.join(__dirname, `../videos/raw/${filename}`);
10 | return path.join(__dirname, `../videos/raw`);
11 | }
12 |
13 | function ensureUploadDirectoryExists(filename: string) {
14 | const dirPath = getUploadDirectoryPath(filename);
15 | if (!existsSync(dirPath)) {
16 | mkdirSync(dirPath);
17 | }
18 | }
19 |
20 | const storage = multer.diskStorage({
21 | destination: (
22 | req: Request,
23 | file: Express.Multer.File,
24 | cb: (error: Error | null, destination: string) => void
25 | ) => {
26 | const fileSpecificFolder = file.originalname.split(
27 | path.extname(file.originalname)
28 | )[0];
29 | ensureUploadDirectoryExists(fileSpecificFolder);
30 | cb(null, getUploadDirectoryPath(fileSpecificFolder));
31 | },
32 | filename: (
33 | req: Request,
34 | file: Express.Multer.File,
35 | cb: (error: Error | null, filename: string) => void
36 | ) => {
37 | cb(null, file.originalname);
38 | },
39 | });
40 |
41 | const fileUploader: Multer = multer({
42 | storage: storage,
43 | limits: {
44 | // fileSize: 2 * 1024 * 1024, // 2 MB
45 | },
46 | fileFilter: (
47 | req: Request,
48 | file: Express.Multer.File,
49 | cb: FileFilterCallback
50 | ) => {
51 | const allowedFileTypes = [".jpg", ".jpeg", ".png", ".pdf", ".mp4", ".mov"];
52 | const fileExtension = path.extname(file.originalname).toLowerCase();
53 |
54 | if (allowedFileTypes.includes(fileExtension)) {
55 | cb(null, true);
56 | } else {
57 | cb(
58 | new HttpError(
59 | "Only .jpg, .jpeg, .png, and .pdf files are allowed.",
60 | 400
61 | )
62 | );
63 | }
64 | },
65 | });
66 |
67 | export default fileUploader;
68 |
--------------------------------------------------------------------------------
/src/controllers/transcodeVideo.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from "express";
2 | import ffmpeg from "fluent-ffmpeg";
3 | import ffmpegInstaller from "@ffmpeg-installer/ffmpeg";
4 | import ffProbeInstaller from "@ffprobe-installer/ffprobe";
5 | import { path } from "@ffmpeg-installer/ffmpeg";
6 | import { resolve, extname } from "path";
7 | import { existsSync, mkdirSync, unlink } from "fs";
8 | import HttpError from "../utils/HttpError";
9 |
10 | export default async function transcodeVideo(
11 | req: Request,
12 | res: Response,
13 | next: NextFunction
14 | ) {
15 | try {
16 | if (!req.file)
17 | return next(new HttpError("Upload a file to begin transcoding", 400));
18 | // const inputFileName = req.params.name;
19 |
20 | const outputDirName = req.file.originalname.split(
21 | extname(req.file.originalname)
22 | )[0];
23 |
24 | const inputFileName = req.file.originalname;
25 | const inputFilePath = resolve(__dirname, `../videos/raw/${inputFileName}`);
26 | const filename = inputFileName.split(".")[0];
27 | /**
28 | * Directory to store transcoded files
29 | * videos
30 | * |- raw : video source
31 | * |- transcoded
32 | * |- videoName
33 | * |- .hls file
34 | * |- .ts files
35 | */
36 | const outputDir = resolve(
37 | __dirname,
38 | `../videos/transcoded/${outputDirName}`
39 | );
40 | const manifestPath = `${outputDir}/${outputDirName}.m3u8`;
41 |
42 | if (!existsSync(outputDir)) {
43 | mkdirSync(outputDir, { recursive: true });
44 | }
45 |
46 | const command = ffmpeg(inputFilePath)
47 | .setFfmpegPath(ffmpegInstaller.path)
48 | .setFfprobePath(ffProbeInstaller.path)
49 | .outputOptions([
50 | "-hls_time 15", // Set segment duration (in seconds)
51 | "-hls_list_size 0", // Allow an unlimited number of segments in the playlist
52 | ])
53 | .output(`${outputDir}/${outputDirName}.m3u8`);
54 |
55 | command.outputOptions("-c:v h264");
56 | command.outputOptions("-c:a aac");
57 |
58 | command.on("end", () => {
59 | unlink(inputFilePath, (err) => {
60 | if (err) throw new HttpError((err as Error).message, 500);
61 | console.info(
62 | `Transcoding completed. Raw file removed from ${inputFilePath}`
63 | );
64 | });
65 | res
66 | .status(200)
67 | .json({ message: "Transcoding completed", manifest: manifestPath });
68 | });
69 |
70 | command.output(manifestPath).run();
71 | // ffmpegCommand.outputOptions("-c:v h264");
72 | // ffmpegCommand.outputOptions("-c:a aac");
73 | } catch (error) {
74 | console.error(`Transcoding failed: `, error);
75 | return res.status(500).json(error);
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Node-Express Video Transcoder API
2 |
3 | A simple node-express API that lets you transcode videos and generate corresponding`.hls` and `.ts` files.
4 |
5 | ### [Medium Article](https://medium.com/javascript-in-plain-english/building-a-simple-video-transcoding-service-with-node-and-ffmpeg-271b2e73d5e0)
6 |
7 | ### Future updates will allow users to
8 |
9 | - `upload` videos to the server
10 | - `download` the transcoded result
11 | - upload the transcoded files to `AWS S3`
12 | - access the video through `AWS Cloudfront CDN`
13 |
14 | ## 👨🏻💻 Getting Stated
15 |
16 | - run `npm i` to install all project dependencies.
17 | - run `npm run dev` to start the dev server.
18 | - **v1.0.0**: ~~add video(s) in `'./src/videos/raw` folder. For ease, there is already a video and its transcoded files added in `./src/videos` folder. All you need to do is open the `./index.html` file.~~
19 |
20 | ## 📦 Begin Transcoding
21 |
22 | ### Version 2.0.0
23 |
24 | - Once you have the project running on localhost, upload a new video file using the `POST /transcode/new` endpoint.
25 | - Once the video gets uploaded, transcoding starts immediately and just like in v1.0.0, generated output is stored in the `./src/videos/transcoded` folder.
26 | - Once transcoding is completed, the uploaded file is removed from the local `fs`.
27 |
28 | ### Version 1.0.0
29 |
30 | - copy the complete video name, including the file extension, of the file that you want to transcode, say `my-video.mp4`.
31 | - for `v1.0.0`, you can either use your browser or terminal to send a `GET` request to `htp://localhost:4000/transcode/new/my-video.mp4`
32 |
33 | ## 📺 Consuming the `.hls` file and video chunks
34 |
35 | - in the `./index.html` file, change the `src` value for the `` element to point to the `.hls` file for your video.
36 | - open the `.html` file and the network tab (in the dev tools), side by side.
37 | - you will be able to see multiple requests being sent for `.ts` files, which is nothing but the browser requesting for more chunks as the video keeps playing.
38 | - stop playback and no more chunks will be fetched.
39 | - skip to a new video position and a new chunk will be fetched.
40 | - jump back to a point in video which has already played and you'll see that the chunk was loaded immediately from `disk cache`.
41 |
42 | ## ❗️Important
43 |
44 | > Please read CHANGELOG.md and check the version number in package.json to make sure you're following the right steps to get stuff done.
45 |
46 | > Transcoder runs on port 4000 by default. If you wish to change this, you can either add a .env file at the root level, or directly change the port number in './src/index.ts
47 |
48 | > time taken for transcoding to complete will depend upon the file size of the video being used.
49 |
50 | > Video in the `./src/videos/raw` folder has been downloaded from YouTube for the purpose of this project and not for re-distribution or anything.
51 |
--------------------------------------------------------------------------------