diff --git a/packages/cli/src/commands/normalize-audio.test.ts b/packages/cli/src/commands/normalize-audio.test.ts index 87f1f6ce47..a9a93753b6 100644 --- a/packages/cli/src/commands/normalize-audio.test.ts +++ b/packages/cli/src/commands/normalize-audio.test.ts @@ -175,3 +175,13 @@ describe("updateAudioVolume", () => { ); }); }); + +describe("normalize-audio on video clips", () => { + it("finds a video with sound as a target and skips a muted one", () => { + const html = ``; + expect(audioTags(html).map((tag) => tag.id)).toEqual(["a-roll", "music"]); + expect(updateAudioVolume(html, "a-roll", 0.5)).toContain( + ``, + ); + }); +}); diff --git a/packages/cli/src/commands/normalize-audio.ts b/packages/cli/src/commands/normalize-audio.ts index e85528ca6f..f31ef96336 100644 --- a/packages/cli/src/commands/normalize-audio.ts +++ b/packages/cli/src/commands/normalize-audio.ts @@ -1,410 +1,36 @@ -import { execFile } from "node:child_process"; import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { isAbsolute, relative, resolve } from "node:path"; -import { promisify } from "node:util"; -import { formatAudioGain, MAX_AUDIO_GAIN_DB } from "@hyperframes/core/audio-gain"; -import { findFfBinary } from "@hyperframes/parsers/ff-binaries"; +import { relative } from "node:path"; +import { + absoluteLoudnessPlan, + audioNormalizationPlan, + audioTags, + DEFAULT_TARGET_LUFS, + measureAudio, + requiredFfmpeg, + resolveLocalAudioPath, + updateAudioVolume, + type AbsoluteLoudnessPlan, + type AudioNormalizationPlan, + type AudioTag, + type LoudnessLimit, +} from "@hyperframes/studio-server/loudness"; import { defineCommand } from "citty"; import { c } from "../ui/colors.js"; import { resolveProject } from "../utils/project.js"; import { failCommand } from "../utils/commandResult.js"; import type { Example } from "./_examples.js"; -const execFileAsync = promisify(execFile); -const FFMPEG_TIMEOUT_MS = 120_000; -const DEFAULT_TOLERANCE_LU = 0.5; - -interface AttributeSpan { - value: string; - valueStart: number; - valueEnd: number; -} - -interface TagRange { - start: number; - end: number; -} - -interface ParsedStartTag extends TagRange { - closing: boolean; - name: string; -} - -interface ScanResult { - cursor: number; - audioRange: TagRange | null; -} - -export interface AudioTag { - id: string; - src: string; - volume: number; - mediaStart: number; - duration: number | null; - start: number; - end: number; - volumeAttribute: AttributeSpan | null; -} - -export interface LoudnessMeasurement { - integratedLufs: number; - truePeakDbfs: number; -} - -export interface NormalizationTrack extends LoudnessMeasurement { - id: string; - volume: number; -} - -export interface AudioNormalizationPlan { - referenceId: string; - targetId: string; - referenceLufs: number; - targetLufs: number; - previousVolume: number; - volume: number; - gainDb: number; - changeDb: number; - projectedLufs: number; - projectedTruePeakDbfs: number; -} - -function authoredNumber( - raw: string | undefined, - fallback: number, - attribute: string, - id: string, - strictlyPositive = false, -): number { - if (raw === undefined) return fallback; - const value = Number(raw); - if (!Number.isFinite(value) || (strictlyPositive ? value <= 0 : value < 0)) { - throw new Error(`Audio #${id} has an invalid ${attribute}: ${raw}`); - } - return value; -} - -function attributeFromMatch( - match: RegExpMatchArray, - absoluteOffset: number, -): [string, AttributeSpan] | null { - if (match.index === undefined) return null; - const name = match[1]?.toLowerCase(); - const value = matchedAttributeValue(match); - if (!name || value === undefined) return null; - - const whole = match[0]; - const localValueStart = attributeValueStart(whole); - const valueStart = absoluteOffset + match.index + localValueStart; - return [name, { value, valueStart, valueEnd: valueStart + value.length }]; -} - -function matchedAttributeValue(match: RegExpMatchArray): string | undefined { - return match[2] ?? match[3] ?? match[4]; -} - -function attributeValueStart(attribute: string): number { - let cursor = attribute.indexOf("=") + 1; - while (/\s/.test(attribute[cursor] ?? "")) cursor += 1; - const quote = attribute[cursor]; - return quote === '"' || quote === "'" ? cursor + 1 : cursor; -} - -function attributesInTag(html: string, from: number, to: number): Map { - const tag = html.slice(from, to); - const nameEnd = tag.search(/\s|\/?\s*>/); - const attributes = new Map(); - if (nameEnd < 0) return attributes; - - const pattern = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g; - for (const match of tag.slice(nameEnd).matchAll(pattern)) { - const attribute = attributeFromMatch(match, from + nameEnd); - if (attribute) attributes.set(...attribute); - } - return attributes; -} - -function startTagEnd(html: string, start: number): number { - let quote = ""; - let end = start + 1; - for (; end < html.length; end += 1) { - const char = html[end]; - if (quote) { - if (char === quote) quote = ""; - } else if (char === '"' || char === "'") { - quote = char; - } else if (char === ">") { - return end + 1; - } - } - throw new Error("Unterminated HTML start tag"); -} - -function commentEnd(html: string, start: number): number { - const end = html.indexOf("-->", start + 4); - if (end < 0) throw new Error("Unterminated HTML comment"); - return end + 3; -} - -function parsedStartTag(html: string, start: number): ParsedStartTag | null { - const match = html.slice(start).match(/^<\s*(\/?)\s*([a-z][a-z\d:-]*)\b/i); - if (!match) return null; - return { - start, - end: startTagEnd(html, start), - closing: match[1] === "/", - name: match[2]?.toLowerCase() ?? "", - }; -} - -function rawTextElementEnd(html: string, lower: string, tag: ParsedStartTag): number | null { - if (tag.closing || (tag.name !== "script" && tag.name !== "style")) return null; - const closeStart = lower.indexOf(` element`); - return startTagEnd(html, closeStart); -} - -function scanMarkup(html: string, lower: string, start: number): ScanResult { - if (html.startsWith("", start + 4); + if (end < 0) throw new Error("Unterminated HTML comment"); + return end + 3; +} + +const isSpaceAt = (html: string, index: number) => /\s/.test(html.charAt(index)); +const isWordAt = (html: string, index: number) => /\w/.test(html.charAt(index)); + +function skipSpaces(html: string, index: number): number { + let cursor = index; + while (cursor < html.length && isSpaceAt(html, cursor)) cursor++; + return cursor; +} + +function parsedStartTag(html: string, start: number): ParsedStartTag | null { + let cursor = skipSpaces(html, start + 1); + const closing = html.charAt(cursor) === "/"; + if (closing) cursor = skipSpaces(html, cursor + 1); + const nameStart = cursor; + if (!/[a-z]/i.test(html.charAt(cursor))) return null; + while (/[a-z\d:-]/i.test(html.charAt(cursor + 1))) cursor++; + let nameEnd = cursor + 1; + while (nameEnd > nameStart && isWordAt(html, nameEnd - 1) === isWordAt(html, nameEnd)) nameEnd--; + if (nameEnd === nameStart) return null; + return { + start, + end: startTagEnd(html, start), + closing, + name: html.slice(nameStart, nameEnd).toLowerCase(), + }; +} + +function rawTextElementEnd(html: string, lower: string, tag: ParsedStartTag): number | null { + if (tag.closing || (tag.name !== "script" && tag.name !== "style")) return null; + const closeStart = lower.indexOf(` element`); + return startTagEnd(html, closeStart); +} + +function scanMarkup(html: string, lower: string, start: number): ScanResult { + if (html.startsWith("