diff --git a/packages/studio-server/src/helpers/loudness.test.ts b/packages/studio-server/src/helpers/loudness.test.ts
index 898d9093d2..638abb66c6 100644
--- a/packages/studio-server/src/helpers/loudness.test.ts
+++ b/packages/studio-server/src/helpers/loudness.test.ts
@@ -95,6 +95,40 @@ describe("audioTags with video", () => {
});
});
+describe("audioTags with HTML character references", () => {
+ it.each([
+ { src: "Q&A.wav", expected: "Q&A.wav" },
+ { src: "Q&A.wav", expected: "Q&A.wav" },
+ { src: "Q&A.wav", expected: "Q&A.wav" },
+ { src: "an's "reply".wav", expected: 'an\'s "reply".wav' },
+ { src: "Q&A.wav", expected: "Q&A.wav" },
+ ])("decodes $src once for local file lookup", ({ src, expected }) => {
+ const html = ``;
+ expect(audioTags(html)[0]?.src).toBe(expected);
+ });
+
+ it("patches an entity-encoded id while preserving every other authored byte", () => {
+ const html = ``;
+ expect(audioTags(html)[0]).toEqual(expect.objectContaining({ id: "voice-over", volume: 1 }));
+ expect(updateAudioVolume(html, "voice-over", 0.5)).toBe(
+ ``,
+ );
+ });
+
+ it("decodes video audibility and timing attributes before interpreting them", () => {
+ const html = ``;
+ expect(audioTags(html)[0]).toEqual(
+ expect.objectContaining({
+ id: "vo",
+ src: "Q&A.mp4",
+ mediaStart: 2,
+ duration: 3,
+ playbackRate: 1,
+ }),
+ );
+ });
+});
+
describe("loudnessMeasureArgs with a playback rate", () => {
it("measures the source span a sped-up clip actually plays", () => {
const args = loudnessMeasureArgs("clip.mp4", { mediaStart: 0, duration: 4, playbackRate: 2 });
diff --git a/packages/studio-server/src/helpers/loudness.ts b/packages/studio-server/src/helpers/loudness.ts
index 7f13d8ff06..f104894205 100644
--- a/packages/studio-server/src/helpers/loudness.ts
+++ b/packages/studio-server/src/helpers/loudness.ts
@@ -3,6 +3,7 @@ import { isAbsolute, relative, resolve } from "node:path";
import { promisify } from "node:util";
import { isAudibleVideoElement } from "@hyperframes/core/audible-video";
import { formatAudioGain, MAX_AUDIO_GAIN_DB } from "@hyperframes/core/audio-gain";
+import { decodeAuthoredAttribute } from "@hyperframes/parsers";
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
import { requestedProjectPath } from "./requestSubPath.js";
@@ -101,7 +102,14 @@ function attributeFromMatch(
const whole = match[0];
const localValueStart = attributeValueStart(whole);
const valueStart = absoluteOffset + match.index + localValueStart;
- return [name, { value, valueStart, valueEnd: valueStart + value.length }];
+ return [
+ name,
+ {
+ value: decodeAuthoredAttribute(value),
+ valueStart,
+ valueEnd: valueStart + value.length,
+ },
+ ];
}
function matchedAttributeValue(match: RegExpMatchArray): string | undefined {