Skip to content

Commit

Permalink
feat: HEVC support
Browse files Browse the repository at this point in the history
  • Loading branch information
matvp91 committed Sep 16, 2024
1 parent 7d026f2 commit fa3d524
Show file tree
Hide file tree
Showing 4 changed files with 235 additions and 190 deletions.
5 changes: 2 additions & 3 deletions packages/artisan/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
},
"devDependencies": {
"@types/find-config": "^1.0.4",
"@types/fluent-ffmpeg": "^2.1.25",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.1.0",
"@types/parse-filepath": "^1.0.2",
Expand All @@ -32,11 +31,11 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.623.0",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"bullmq": "^5.12.0",
"dotenv": "^16.4.5",
"ffmpeg-static": "^5.2.0",
"ffmpeggy": "^3.0.1",
"find-config": "^1.0.0",
"fluent-ffmpeg": "^2.1.3",
"glob": "^11.0.0",
"iso-language-codes": "^2.0.0",
"mime-types": "^2.1.35",
Expand Down
199 changes: 125 additions & 74 deletions packages/artisan/src/consumer/workers/ffmpeg.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { dirSync } from "tmp";
import ffmpeg from "fluent-ffmpeg";
import { downloadFile, uploadFile } from "../s3.js";
import parseFilePath from "parse-filepath";
import ffmpegBin from "@ffmpeg-installer/ffmpeg";
import { FFmpeggy } from "ffmpeggy";
import ffmpegBin from "ffmpeg-static";
import type { Job } from "bullmq";
import type { Stream, Input } from "../../schemas.js";
import type { FfmpegCommand } from "fluent-ffmpeg";

console.log(`Set ffmpeg path to "${ffmpegBin.path}"`);
if (!ffmpegBin) {
throw new Error("Cannot find ffmpeg bin");
}

ffmpeg.setFfmpegPath(ffmpegBin.path);
FFmpeggy.DefaultConfig = {
...FFmpeggy.DefaultConfig,
ffmpegBin,
};

export type FfmpegData = {
params: {
Expand All @@ -28,103 +32,85 @@ export type FfmpegResult = {
stream: Stream;
};

export default async function (job: Job<FfmpegData, FfmpegResult>) {
const { params } = job.data;

const dir = dirSync();
let inputFile = parseFilePath(params.input.path);
async function prepareInput(input: Input) {
const filePath = parseFilePath(input.path);

if (inputFile.dir.startsWith("s3://")) {
const s3SourcePath = inputFile.path.replace("s3://", "");
if (filePath.dir.startsWith("s3://")) {
// If the input is on S3, download the file locally.
const dir = dirSync();

job.log(`Source is on s3, downloading ${s3SourcePath} to ${dir.name}`);
const s3SourcePath = filePath.path.replace("s3://", "");

await downloadFile(dir.name, s3SourcePath);
inputFile = parseFilePath(`${dir.name}/${inputFile.basename}`);

return parseFilePath(`${dir.name}/${filePath.basename}`);
}

return filePath;
}

export default async function (job: Job<FfmpegData, FfmpegResult>) {
const { params } = job.data;

const dir = dirSync();

const inputFile = await prepareInput(params.input);

job.log(`Input is ${inputFile.path}`);

const ffmpeg = new FFmpeggy({
input: inputFile.path,
globalOptions: ["-loglevel error"],
});

let name: string | undefined;
let ffmpegCmd: FfmpegCommand | undefined;

if (params.stream.type === "video") {
const keyFrameInterval = params.segmentSize * params.stream.framerate;

let codec: string;
switch (params.stream.codec) {
case "h264":
codec = "libx264";
break;
default:
codec = params.stream.codec;
break;
}
const outputOptions: string[] = [];

if (params.stream.type === "video") {
name = `video_${params.stream.height}_${params.stream.bitrate}_${params.stream.codec}.m4v`;

ffmpegCmd = ffmpeg(inputFile.path)
.noAudio()
.format("mp4")
.size(`?x${params.stream.height}`)
.aspectRatio("16:9")
.autoPad(true)
.videoCodec(codec)
.videoBitrate(params.stream.bitrate)
.outputOptions([
`-frag_duration ${params.segmentSize * 1e6}`,
"-movflags +frag_keyframe",
`-r ${params.stream.framerate}`,
`-keyint_min ${keyFrameInterval}`,
`-g ${keyFrameInterval}`,
])
.output(`${dir.name}/${name}`);
outputOptions.push(
...getVideoOutputOptions(params.stream, params.segmentSize),
);
}

if (params.stream.type === "audio") {
name = `audio_${params.stream.language}_${params.stream.bitrate}.m4a`;

ffmpegCmd = ffmpeg(inputFile.path)
.noVideo()
.format("mp4")
.audioCodec(params.stream.codec)
.audioBitrate(params.stream.bitrate)
.outputOptions([
`-metadata language=${params.stream.language}`,
`-frag_duration ${params.segmentSize * 1e6}`,
])
.output(`${dir.name}/${name}`);
outputOptions.push(
...getAudioOutputOptions(params.stream, params.segmentSize),
);
}

if (params.stream.type === "text") {
name = `text_${params.stream.language}.vtt`;

ffmpegCmd = ffmpeg(inputFile.path).output(`${dir.name}/${name}`);
outputOptions.push(...getTextOutputOptions(params.stream));
}

if (!ffmpegCmd || !name) {
throw new Error("No ffmpeg cmd or file created.");
if (!name) {
throw new Error(
"Missing name, this is most likely a bug. Report it, please.",
);
}

ffmpeg.setOutput(`${dir.name}/${name}`);
ffmpeg.setOutputOptions(outputOptions);

job.log(`Transcode to ${name}`);

await new Promise((resolve, reject) => {
ffmpegCmd
.on("error", reject)
.on("end", () => {
job.updateProgress(100);
job.log("Finished transcode");
resolve(undefined);
})
.on("start", (cmdLine) => {
job.log(cmdLine);
})
.on("progress", (event) => {
job.updateProgress(event.percent ?? 0);
})
.run();
ffmpeg.on("start", (args) => {
job.log(args.join(" "));
});

ffmpeg.on("progress", (event) => {
job.updateProgress(event.percent ?? 0);
});

ffmpeg.run();

await ffmpeg.done();

job.updateProgress(100);

job.log(
`Uploading ${dir.name}/${name} to transcode/${params.assetId}/${name}`,
);
Expand All @@ -139,3 +125,68 @@ export default async function (job: Job<FfmpegData, FfmpegResult>) {
stream: params.stream,
};
}

function getVideoOutputOptions(
stream: Extract<Stream, { type: "video" }>,
segmentSize: number,
) {
const args: string[] = [
"-f mp4",
"-an",
`-c:v ${stream.codec}`,
`-b:v ${stream.bitrate}`,
`-r ${stream.framerate}`,
"-movflags +frag_keyframe",
`-frag_duration ${segmentSize * 1_000_000}`,
];

if (stream.codec === "h264") {
let profile = "main";
if (stream.height >= 720) {
profile = "high";
}
args.push(`-profile:v ${profile}`);
}

if (stream.codec === "h264" || stream.codec === "hevc") {
args.push(
"-preset slow",
"-flags +loop",
"-pix_fmt yuv420p",
"-flags +cgop",
);
}

const filters: string[] = ["setsar=1:1", `scale=-2:${stream.height}`];

args.push(`-vf ${filters.join(",")}`);

const keyFrameRate = segmentSize * stream.framerate;
args.push(`-keyint_min ${keyFrameRate}`, `-g ${keyFrameRate}`);

return args;
}

function getAudioOutputOptions(
stream: Extract<Stream, { type: "audio" }>,
segmentSize: number,
) {
const args: string[] = [
"-f mp4",
"-vn",
"-ac 2",
`-c:a ${stream.codec}`,
`-b:a ${stream.bitrate}`,
`-frag_duration ${segmentSize * 1_000_000}`,
`-metadata language=${stream.language}`,
"-strict experimental",
];

return args;
}

function getTextOutputOptions(stream: Extract<Stream, { type: "text" }>) {
const args: string[] = ["-f webvtt"];

return args;
}
2 changes: 1 addition & 1 deletion packages/artisan/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { zodEnumLanguage } from "./lib/zod-helpers.js";
export const streamSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("video"),
codec: z.enum(["h264", "vp9"]),
codec: z.enum(["h264", "vp9", "hevc"]),
height: z.number(),
bitrate: z.number(),
framerate: z.number(),
Expand Down
Loading

0 comments on commit fa3d524

Please sign in to comment.