diff --git a/packages/cli/src/commands/validate.media-fit.test.ts b/packages/cli/src/commands/validate.media-fit.test.ts
new file mode 100644
index 00000000000..bbb6158cc02
--- /dev/null
+++ b/packages/cli/src/commands/validate.media-fit.test.ts
@@ -0,0 +1,77 @@
+// @vitest-environment happy-dom
+import { analyzeClipMediaFit } from "@hyperframes/engine";
+import type { Page } from "puppeteer-core";
+import { afterEach, describe, expect, it } from "vitest";
+import { auditClipDurations } from "./validate.js";
+
+afterEach(() => {
+ document.body.innerHTML = "";
+});
+
+async function auditAudio(attributes: string, sourceDuration: number) {
+ document.body.innerHTML = ``;
+ const audio = document.querySelector("audio");
+ if (!audio) throw new Error("Missing fixture audio");
+ Object.defineProperty(audio, "duration", { value: sourceDuration });
+
+ const evaluate: Page["evaluate"] = async (fn, ...args) => {
+ if (typeof fn !== "function") throw new Error("Expected a browser function");
+ return window.eval(`(${fn.toString()})`)(...args);
+ };
+ return auditClipDurations({ evaluate }, analyzeClipMediaFit, 0);
+}
+
+describe("audio slot fit at the authored playback speed", () => {
+ it.each([
+ { name: "normal speed", attributes: 'data-duration="1"', sourceDuration: 1 },
+ {
+ name: "half speed",
+ attributes: 'data-duration="2" data-playback-rate=".5"',
+ sourceDuration: 1,
+ },
+ {
+ name: "trimmed half speed",
+ attributes: 'data-duration="4" data-media-start="2" data-playback-rate=".5"',
+ sourceDuration: 4,
+ },
+ {
+ name: "rate lane overriding the constant rate",
+ attributes: `data-duration="2" data-playback-rate="2"
+ data-automation='{"version":1,"lanes":[{"target":"rate","points":[{"t":0,"v":0.5},{"t":2,"v":0.5}]}]}'`,
+ sourceDuration: 1,
+ },
+ {
+ name: "rising rate lane",
+ attributes: `data-duration="2"
+ data-automation='{"version":1,"lanes":[{"target":"rate","points":[{"t":0,"v":0.5},{"t":2,"v":1}]}]}'`,
+ sourceDuration: 1.5,
+ },
+ {
+ name: "canonical trim overriding the legacy offset",
+ attributes:
+ 'data-duration="4" data-playback-start="2" data-media-start="3" data-playback-rate=".5"',
+ sourceDuration: 4,
+ },
+ {
+ name: "looping fast audio",
+ attributes: 'data-duration="2" data-playback-rate="2" loop',
+ sourceDuration: 1,
+ },
+ ])("does not flag $name when it fills the slot", async ({ attributes, sourceDuration }) => {
+ expect(await auditAudio(attributes, sourceDuration)).toEqual([]);
+ });
+
+ it.each([
+ { name: "normal speed", attributes: 'data-duration="2"', expected: "1.00" },
+ {
+ name: "double speed",
+ attributes: 'data-duration="1" data-playback-rate="2"',
+ expected: "0.50",
+ },
+ ])("reports the playable timeline length at $name", async ({ attributes, expected }) => {
+ const warnings = await auditAudio(attributes, 1);
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0]?.text).toContain(`Audio "voice" is ${expected}s`);
+ expect(warnings[0]?.text).toContain(`Set data-duration to ~${expected}s`);
+ });
+});
diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts
index 7c2497cb0d2..348170e83a7 100644
--- a/packages/cli/src/commands/validate.ts
+++ b/packages/cli/src/commands/validate.ts
@@ -14,6 +14,7 @@ import { resolveProject, type ProjectDir } from "../utils/project.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import type { ProjectLintResult } from "../utils/lintProject.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
+import { resolveNaturalMediaTimelineDuration } from "@hyperframes/core";
import { c } from "../ui/colors.js";
import { decodeWellFormedEscapes } from "@hyperframes/studio-server";
import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js";
@@ -155,7 +156,7 @@ export function raceMediaReady(
* can't see.
*/
export async function auditClipDurations(
- page: import("puppeteer-core").Page,
+ page: Pick,
analyzeClipMediaFit: typeof import("@hyperframes/engine").analyzeClipMediaFit,
extraWaitMs: number,
): Promise {
@@ -200,7 +201,7 @@ export async function auditClipDurations(
id: string;
kind: string;
slot: number;
- mediaStart: number;
+ attributes: Record;
duration: number;
loop: boolean;
}> = [];
@@ -211,7 +212,12 @@ export async function auditClipDurations(
id: el.id || el.getAttribute("src") || `(${el.tagName.toLowerCase()})`,
kind: el.tagName === "AUDIO" ? "Audio" : "Video",
slot,
- mediaStart: parseFloat(el.getAttribute("data-media-start") ?? "0") || 0,
+ attributes: {
+ "data-media-start": el.getAttribute("data-media-start"),
+ "data-playback-start": el.getAttribute("data-playback-start"),
+ "data-playback-rate": el.getAttribute("data-playback-rate"),
+ "data-automation": el.getAttribute("data-automation"),
+ },
duration: el.duration,
loop: el.loop || el.getAttribute("data-loop") === "true",
});
@@ -228,7 +234,11 @@ export async function auditClipDurations(
unreadable.push(clip.id);
continue;
}
- const mediaSeconds = Math.max(0, clip.duration - clip.mediaStart);
+ const mediaSeconds = resolveNaturalMediaTimelineDuration(
+ { getAttribute: (name) => clip.attributes[name] ?? null },
+ clip.duration,
+ );
+ if (mediaSeconds === null) continue;
const fit = analyzeClipMediaFit({ slotSeconds: clip.slot, mediaSeconds, loop: clip.loop });
if (!fit) continue;
warnings.push({