Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support ac3 (5.1) audio - 6 channels #65

Merged
merged 23 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ const app = new Elysia()
codec: AudioCodecSchema,
bitrate: t.Number({ description: "Bitrate in bps" }),
language: LangCodeSchema,
channels: t.Number(),
}),
t.Object({
type: t.Literal("text"),
Expand Down
11 changes: 9 additions & 2 deletions packages/api/src/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,18 @@ async function formatJobNode(node: JobNode): Promise<Job> {
};
}

// Keep these in sync with ocnsumer/workers/helpers.ts in artisan,
// we can treat the result as a string literal to indicate non standard
// job states such as "skipped".
type JobReturnValueStatus = "__JOB_SKIPPED__";

function mapJobState(
jobState: JobState | "unknown",
returnValue: unknown,
maybeReturnValue?: JobReturnValueStatus,
): Job["state"] {
if (typeof returnValue === "string" && returnValue === "skipped") {
// We pass maybeReturnValue as "any" from the input, it's not typed. But we
// can check whether it is a defined return value for non standard job states.
if (maybeReturnValue === "__JOB_SKIPPED__") {
return "skipped";
}
if (jobState === "active" || jobState === "waiting-children") {
Expand Down
48 changes: 8 additions & 40 deletions packages/artisan/src/consumer/meta-file.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,18 @@
import { Type as t, type Static } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import * as fs from "node:fs/promises";
import {
VideoCodecSchema,
AudioCodecSchema,
LangCodeSchema,
} from "@mixwave/shared";
import type { Stream } from "../types";

/**
* Versioned schema of the meta file.
*/
const metaSchema = t.Object({
version: t.Number(),
streams: t.Record(
t.String(),
t.Union([
t.Object({
type: t.Literal("video"),
codec: VideoCodecSchema,
height: t.Number(),
bitrate: t.Number(),
framerate: t.Number(),
}),
t.Object({
type: t.Literal("audio"),
codec: AudioCodecSchema,
bitrate: t.Number(),
language: LangCodeSchema,
}),
t.Object({
type: t.Literal("text"),
language: LangCodeSchema,
}),
]),
),
segmentSize: t.Number(),
});

export type MetaFile = Static<typeof metaSchema>;
export type MetaFile = {
version: number;
streams: Record<string, Stream>;
segmentSize: number;
};

/**
* Will fetch meta file when meta.json is found in path.
* @param path S3 dir
* @returns
*/
export async function getMetaFile(path: string) {
export async function getMetaFile(path: string): Promise<MetaFile> {
const text = await fs.readFile(`${path}/meta.json`, "utf8");
return Value.Parse(metaSchema, JSON.parse(text));
return JSON.parse(text);
}
47 changes: 32 additions & 15 deletions packages/artisan/src/consumer/workers/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { FFmpeggy } from "ffmpeggy";
import { downloadFile, uploadFile } from "../s3";
import { TmpDir } from "../tmp-dir";
import { getBinaryPath } from "../helpers";
import { SKIP_JOB } from "./helpers";
import { JOB_SKIPPED } from "./helpers";
import type { FFprobeResult } from "ffmpeggy";
import type { Job } from "bullmq";
import type { Stream, Input } from "../../types";
import type { SkippableJobResult } from "./helpers";
Expand Down Expand Up @@ -80,18 +81,13 @@ async function runJob(
const outputOptions: string[] = [];

if (params.stream.type === "video") {
const maxHeight = inputInfo.streams.reduce<number>((acc, stream) => {
if (!stream.height) {
return acc;
}
return acc > stream.height ? acc : stream.height;
}, 0);

if (maxHeight && params.stream.height > maxHeight) {
const maxHeight = getMaxHeight(inputInfo);

if (maxHeight > 0 && params.stream.height > maxHeight) {
job.log(
`Skip upscale, requested ${params.stream.height} is larger than input ${maxHeight}`,
);
return SKIP_JOB;
return JOB_SKIPPED;
}

name = `video_${params.stream.height}_${params.stream.bitrate}_${params.stream.codec}.m4v`;
Expand Down Expand Up @@ -160,6 +156,8 @@ function getVideoOutputOptions(
stream: Extract<Stream, { type: "video" }>,
segmentSize: number,
) {
const keyFrameRate = segmentSize * stream.framerate;

const args: string[] = [
"-f mp4",
"-an",
Expand All @@ -168,6 +166,8 @@ function getVideoOutputOptions(
`-r ${stream.framerate}`,
"-movflags +frag_keyframe",
`-frag_duration ${segmentSize * 1_000_000}`,
`-keyint_min ${keyFrameRate}`,
`-g ${keyFrameRate}`,
];

if (stream.codec === "h264") {
Expand All @@ -189,10 +189,9 @@ function getVideoOutputOptions(

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}`);
if (filters.length) {
args.push(`-vf ${filters.join(",")}`);
}

return args;
}
Expand All @@ -204,18 +203,36 @@ function getAudioOutputOptions(
const args: string[] = [
"-f mp4",
"-vn",
"-ac 2",
`-ac ${stream.channels}`,
`-c:a ${stream.codec}`,
`-b:a ${stream.bitrate}`,
`-frag_duration ${segmentSize * 1_000_000}`,
`-metadata language=${stream.language}`,
"-strict experimental",
];

const filters: string[] = [];
if (stream.channels === 6) {
filters.push("channelmap=channel_layout=5.1");
}

if (filters.length) {
args.push(`-af ${filters.join(",")}`);
}

return args;
}

function getTextOutputOptions() {
const args: string[] = ["-f webvtt"];
return args;
}

function getMaxHeight(info: FFprobeResult) {
return info.streams.reduce<number>((acc, stream) => {
if (!stream.height) {
return acc;
}
return acc > stream.height ? acc : stream.height;
}, 0);
}
4 changes: 2 additions & 2 deletions packages/artisan/src/consumer/workers/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export const SKIP_JOB = "skipped";
export const JOB_SKIPPED = "__JOB_SKIPPED__";

export type SkippableJobResult<T> = typeof SKIP_JOB | T;
export type SkippableJobResult<T> = typeof JOB_SKIPPED | T;
46 changes: 36 additions & 10 deletions packages/artisan/src/consumer/workers/package.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { execa } from "execa";
import { lookup } from "mime-types";
import { by639_2T } from "iso-language-codes";
import parseFilePath from "parse-filepath";
import { downloadFolder, uploadFolder } from "../s3";
import { TmpDir } from "../tmp-dir";
import { getMetaFile } from "../meta-file";
import { getBinaryPath } from "../helpers";
import type { Job } from "bullmq";
import type { Code } from "iso-language-codes";
import type { Stream } from "../../types";

const packagerBin = await getBinaryPath("packager");

Expand All @@ -26,10 +25,6 @@ export type PackageResult = {
assetId: string;
};

function formatLanguage(code: Code) {
return code.name.split(",")[0].toUpperCase();
}

async function runJob(
job: Job<PackageData, PackageResult>,
tmpDir: TmpDir,
Expand Down Expand Up @@ -74,8 +69,8 @@ async function runJob(
`init_segment=${file.name}/init.mp4`,
`segment_template=${file.name}/$Number$.m4a`,
`playlist_name=${file.name}/playlist.m3u8`,
"hls_group_id=audio",
`hls_name=${formatLanguage(by639_2T[stream.language])}`,
`hls_group_id=${getGroupId(stream)}`,
`hls_name=${getName(stream)}`,
`language=${stream.language}`,
]);
}
Expand All @@ -86,8 +81,9 @@ async function runJob(
"stream=text",
`segment_template=${file.name}/$Number$.vtt`,
`playlist_name=${file.name}/playlist.m3u8`,
"hls_group_id=text",
`hls_name=${formatLanguage(by639_2T[stream.language])}`,
`hls_group_id=${getGroupId(stream)}`,
`hls_name=${getName(stream)}`,
`language=${stream.language}`,
]);
}
}
Expand Down Expand Up @@ -129,6 +125,36 @@ async function runJob(
};
}

function getGroupId(
stream:
| Extract<Stream, { type: "audio" }>
| Extract<Stream, { type: "text" }>,
) {
if (stream.type === "audio") {
// When we package audio, we split codecs into a separate group.
// The CODECS attribute would else include "ac-3,mp4a.40.2", which will
// make HLS players fail as each CODECS attribute is needs to pass the
// method |isTypeSupported| on MSE.
return `audio_${stream.codec}`;
}
if (stream.type === "text") {
return `text`;
}
}

function getName(
stream:
| Extract<Stream, { type: "audio" }>
| Extract<Stream, { type: "text" }>,
) {
if (stream.type === "audio") {
return `${stream.language}_${stream.codec}`;
}
if (stream.type === "text") {
return `${stream.language}`;
}
}

export default async function (job: Job<PackageData, PackageResult>) {
const tmpDir = new TmpDir();
try {
Expand Down
10 changes: 6 additions & 4 deletions packages/artisan/src/consumer/workers/transcode.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { addPackageJob } from "../../producer";
import { getFakeJob } from "../helpers";
import { uploadJson } from "../s3";
import { SKIP_JOB } from "./helpers";
import { JOB_SKIPPED } from "./helpers";
import type { FfmpegResult } from "./ffmpeg";
import type { Stream } from "../../types";
import type { MetaFile } from "../meta-file";
Expand Down Expand Up @@ -29,7 +29,9 @@ export type TranscodeResult = SkippableJobResult<{
* @param job
* @returns
*/
export default async function (job: Job<TranscodeData, TranscodeResult>) {
export default async function (
job: Job<TranscodeData, TranscodeResult>,
): Promise<TranscodeResult> {
const { params, metadata } = job.data;

const fakeJob = await getFakeJob<TranscodeData>(job);
Expand All @@ -40,7 +42,7 @@ export default async function (job: Job<TranscodeData, TranscodeResult>) {
(acc, [key, value]) => {
if (key.startsWith("bull:ffmpeg")) {
const result: FfmpegResult = value;
if (result === SKIP_JOB) {
if (result === JOB_SKIPPED) {
// We skipped this job, bail out early.
return acc;
}
Expand All @@ -53,7 +55,7 @@ export default async function (job: Job<TranscodeData, TranscodeResult>) {

if (!Object.keys(streams).length) {
job.log("Skip transcode, no streams found");
return SKIP_JOB;
return JOB_SKIPPED;
}

const meta: MetaFile = {
Expand Down
1 change: 1 addition & 0 deletions packages/artisan/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type Stream =
codec: AudioCodec;
bitrate: number;
language: LangCode;
channels: number;
}
| {
type: "text";
Expand Down
4 changes: 1 addition & 3 deletions packages/dashboard/src/components/JobLog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import ArrowDownFromLine from "lucide-react/icons/arrow-down-from-line";
import ArrowUpFromLine from "lucide-react/icons/arrow-up-from-line";

type JobLogProps = {
value: string;
Expand Down
Loading
Loading