diff --git a/packages/studio-server/src/helpers/loudness.test.ts b/packages/studio-server/src/helpers/loudness.test.ts
index f49d16006fe..898d9093d24 100644
--- a/packages/studio-server/src/helpers/loudness.test.ts
+++ b/packages/studio-server/src/helpers/loudness.test.ts
@@ -13,6 +13,39 @@ const track = (integratedLufs: number, truePeakDbfs: number, volume = 1) => ({
truePeakDbfs,
});
+describe("audioTags playback offsets", () => {
+ it.each([
+ ['data-playback-start="4"', 4],
+ ['data-playback-start="4" data-media-start="1"', 4],
+ ['data-playback-start="0" data-media-start="4"', 0],
+ ['data-playback-start="4" data-media-start="invalid"', 4],
+ ['data-playback-start="-1" data-media-start="3"', 3],
+ ['data-playback-start="1.5s" data-media-start="3"', 3],
+ ['data-playback-start="" data-media-start="3"', 3],
+ ['data-playback-start="invalid"', 0],
+ ['data-media-start="3"', 3],
+ ])("measures the selected in-point for %s", (attributes, expected) => {
+ const html = ``;
+ expect(audioTags(html)[0]).toEqual(expect.objectContaining({ mediaStart: expected }));
+ });
+
+ it("measures a video from its playback in-point at its authored speed", () => {
+ const html = ``;
+ const [tag] = audioTags(html);
+ expect(tag).toBeDefined();
+ if (!tag) throw new Error("missing video");
+ const args = loudnessMeasureArgs("talk.mp4", tag);
+ expect(args.slice(args.indexOf("-ss"), args.indexOf("-i"))).toEqual(["-ss", "4", "-t", "4"]);
+ });
+
+ it("preserves the selected in-point when writing the matched gain", () => {
+ const html = ``;
+ const changed = updateAudioVolume(html, "voice", 0.5);
+ expect(changed).toBe(html.replace('data-volume="1"', 'data-volume="0.5"'));
+ expect(audioTags(changed)[0]).toEqual(expect.objectContaining({ mediaStart: 4, volume: 0.5 }));
+ });
+});
+
describe("audioTags with video", () => {
it("includes a video with sound as a normalizable clip", () => {
const html = ``;
diff --git a/packages/studio-server/src/helpers/loudness.ts b/packages/studio-server/src/helpers/loudness.ts
index bdff307b65b..018569307cb 100644
--- a/packages/studio-server/src/helpers/loudness.ts
+++ b/packages/studio-server/src/helpers/loudness.ts
@@ -4,6 +4,7 @@ import { promisify } from "node:util";
import { isAudibleVideoElement } from "@hyperframes/core/audible-video";
import { formatAudioGain, MAX_AUDIO_GAIN_DB } from "@hyperframes/core/audio-gain";
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
+import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
import { requestedProjectPath } from "./requestSubPath.js";
const execFileAsync = promisify(execFile);
@@ -253,6 +254,14 @@ function requiredAudioIdentity(attributes: Map) {
return { id, src };
}
+function authoredMediaStart(attributes: Map, id: string): number {
+ return readMediaOffsetSeconds((name) => {
+ const raw = attributes.get(name)?.value;
+ if (name === "data-media-start") authoredNumber(raw, 0, name, id);
+ return raw;
+ });
+}
+
/** Boolean attributes carry no `=`, so the value map cannot see them. */
function hasBareAttribute(html: string, range: TagRange, name: string): boolean {
const unquoted = html.slice(range.start, range.end).replace(/"[^"]*"|'[^']*'/g, '""');
@@ -298,12 +307,7 @@ function mediaTagFromRange(html: string, range: MediaTagRange): AudioTag | null
id,
src,
volume: authoredNumber(volumeAttribute?.value, 1, "data-volume", id),
- mediaStart: authoredNumber(
- attributes.get("data-media-start")?.value,
- 0,
- "data-media-start",
- id,
- ),
+ mediaStart: authoredMediaStart(attributes, id),
duration: authoredDuration(attributes, id),
playbackRate: authoredNumber(
attributes.get("data-playback-rate")?.value,