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(`${tag.name}`, tag.end);
- if (closeStart < 0) throw new Error(`Unterminated <${tag.name}> 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(`${tag.name}`, tag.end);
+ if (closeStart < 0) throw new Error(`Unterminated <${tag.name}> element`);
+ return startTagEnd(html, closeStart);
+}
+
+function scanMarkup(html: string, lower: string, start: number): ScanResult {
+ if (html.startsWith("