136 lines
3.8 KiB
TypeScript
136 lines
3.8 KiB
TypeScript
/**
|
|
* Analyze the video's audio track using ITU-R BS.1770/EBU R128
|
|
* to extract Integrated Loudness (LUFS-I), Loudness Range (LRA),
|
|
* and True Peak (dBTP). This helps identify which VODs/streamers
|
|
* maintain consistent, listener-friendly audio setups.
|
|
*
|
|
* ## LUFS Reference
|
|
* Apple Music -16 LUFS
|
|
* YouTube -13 LUFS
|
|
* Spotify -14 LUFS
|
|
* Tidal -14 LUFS
|
|
*/
|
|
|
|
import type { Task, Helpers } from "graphile-worker";
|
|
import { PrismaClient } from "../../generated/prisma";
|
|
import { withAccelerate } from "@prisma/extension-accelerate";
|
|
import { getOrDownloadAsset } from "../utils/cache";
|
|
import { env } from "../config/env";
|
|
import { getS3Client } from "../utils/s3";
|
|
import { getNanoSpawn } from "../utils/nanoSpawn";
|
|
import logger from "../utils/logger";
|
|
|
|
const prisma = new PrismaClient().$extends(withAccelerate());
|
|
const ffmpegBin = "ffmpeg";
|
|
|
|
interface Payload {
|
|
vodId: string;
|
|
}
|
|
|
|
interface AudioStats {
|
|
lufsIntegrated: number; // LUFS-I
|
|
lra: number; // Loudness Range
|
|
peak: number; // True Peak (expressed as dBTP ceiling, e.g. -0.1)
|
|
}
|
|
|
|
export async function runFFmpeg(args: string[]): Promise<string> {
|
|
const spawn = await getNanoSpawn();
|
|
|
|
try {
|
|
const result = await spawn(ffmpegBin, args, {
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
});
|
|
|
|
// success → return combined output
|
|
return result.output;
|
|
} catch (err: any) {
|
|
// failure → throw with stderr
|
|
throw new Error(err.output || err.message);
|
|
}
|
|
}
|
|
|
|
function parseEbur128(output: string): AudioStats {
|
|
const lines = output.split("\n").map((l) => l.trim());
|
|
|
|
let lufsIntegrated = NaN;
|
|
let lra = NaN;
|
|
let peak = NaN;
|
|
let inTruePeak = false;
|
|
|
|
for (const line of lines) {
|
|
// Integrated loudness
|
|
const iMatch = line.match(/^I:\s*(-?\d+(\.\d+)?) LUFS/);
|
|
if (iMatch) lufsIntegrated = parseFloat(iMatch[1]);
|
|
|
|
// Loudness range
|
|
const lraMatch = line.match(/^LRA:\s*(-?\d+(\.\d+)?)/);
|
|
if (lraMatch) lra = parseFloat(lraMatch[1]);
|
|
|
|
// True peak
|
|
if (line === "True peak:") inTruePeak = true;
|
|
if (inTruePeak) {
|
|
const pMatch = line.match(/Peak:\s*(-?\d+\.?\d*)\s*dBFS/);
|
|
if (pMatch) {
|
|
peak = parseFloat(pMatch[1]); // no negation
|
|
inTruePeak = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { lufsIntegrated, lra, peak };
|
|
}
|
|
|
|
|
|
async function analyzeAudio(inputFile: string): Promise<AudioStats> {
|
|
const args = [
|
|
"-hide_banner",
|
|
"-i", inputFile,
|
|
"-filter_complex", "ebur128=peak=true:metadata=1:framelog=verbose",
|
|
"-f", "null",
|
|
"-",
|
|
];
|
|
|
|
const output = await runFFmpeg(args);
|
|
return parseEbur128(output);
|
|
}
|
|
|
|
function assertPayload(payload: any): asserts payload is Payload {
|
|
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
|
|
if (typeof payload.vodId !== "string") throw new Error("invalid payload-- was missing vodId");
|
|
}
|
|
|
|
export default async function main(payload: any, helpers: Helpers) {
|
|
assertPayload(payload);
|
|
const { vodId } = payload;
|
|
|
|
const vod = await prisma.vod.findFirstOrThrow({
|
|
where: { id: vodId },
|
|
});
|
|
|
|
if (vod.audioIntegratedLufs && vod.audioLoudnessRange && vod.audioTruePeak) {
|
|
logger.info(`Skipping analysis — vod ${vodId} already complete.`);
|
|
return;
|
|
}
|
|
|
|
if (!vod.sourceVideo) {
|
|
throw new Error(`vod ${vodId} is missing a sourceVideo.`);
|
|
}
|
|
|
|
const s3Client = getS3Client();
|
|
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo);
|
|
logger.info(`videoFilePath=${videoFilePath}`);
|
|
|
|
const results = await analyzeAudio(videoFilePath);
|
|
logger.info(`results=${JSON.stringify(results)}`);
|
|
|
|
await prisma.vod.update({
|
|
where: { id: vodId },
|
|
data: {
|
|
audioIntegratedLufs: results.lufsIntegrated,
|
|
audioLoudnessRange: results.lra,
|
|
audioTruePeak: results.peak,
|
|
},
|
|
});
|
|
}
|