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({