From 495bf398627e832a42d4f2cb80932782c7d7216b Mon Sep 17 00:00:00 2001 From: Paddy <89168564+patrickballard@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:14:10 +0000 Subject: [PATCH] fix(web): keep the audio in every trim, not just the first The trim tool connected the preview video to a new MediaElementAudioSourceNode on every trim, but an element can only ever have one, so from the second trim on the call threw, the catch logged it and the trim was recorded without audio. Create the connection once per preview element and reuse it. --- .github/workflows/ci.yml | 1 + .../unit/trimming-tool-audio.test.ts | 183 ++++++++++++++++++ apps/web/components/tools/TrimmingTool.tsx | 30 ++- 3 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 apps/web/__tests__/unit/trimming-tool-audio.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70bb14815be..11e43ea7b6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,6 +166,7 @@ jobs: __tests__/unit/toggle-pro-seat.test.ts __tests__/unit/transcript-hours.test.ts __tests__/unit/transcript-numbers.test.ts + __tests__/unit/trimming-tool-audio.test.ts __tests__/unit/video-analytics-access.test.ts __tests__/unit/video-analytics-bulk.test.ts __tests__/unit/video-create-atomicity.test.ts diff --git a/apps/web/__tests__/unit/trimming-tool-audio.test.ts b/apps/web/__tests__/unit/trimming-tool-audio.test.ts new file mode 100644 index 00000000000..3e036902ee0 --- /dev/null +++ b/apps/web/__tests__/unit/trimming-tool-audio.test.ts @@ -0,0 +1,183 @@ +// @vitest-environment jsdom + +import { type ButtonHTMLAttributes, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { TrimmingTool } from "@/components/tools/TrimmingTool"; + +vi.mock("@cap/ui", () => ({ + Button: (props: ButtonHTMLAttributes) => + createElement("button", { ...props, type: "button" }), +})); +vi.mock("@/app/utils/analytics", () => ({ trackEvent: vi.fn() })); + +class FakeStream { + tracks: { kind: string }[] = []; + addTrack(track: { kind: string }) { + this.tracks.push(track); + } +} + +let recordedStreams: FakeStream[]; + +class FakeRecorder { + static isTypeSupported = () => true; + state = "inactive"; + ondataavailable?: (event: { data: Blob }) => void; + onstop?: () => void; + constructor(stream: FakeStream) { + recordedStreams.push(stream); + } + start() { + this.state = "recording"; + } + stop() { + this.state = "inactive"; + this.ondataavailable?.({ + data: new Blob(["trimmed"], { type: "video/mp4" }), + }); + this.onstop?.(); + } +} + +class FakeAudioContext { + static connected = new WeakSet(); + destination = {}; + createMediaElementSource(element: HTMLMediaElement) { + if (FakeAudioContext.connected.has(element)) { + throw new DOMException( + "HTMLMediaElement already connected previously to a different MediaElementSourceNode.", + "InvalidStateError", + ); + } + FakeAudioContext.connected.add(element); + return { connect: () => {} }; + } + createMediaStreamDestination() { + return { stream: { getAudioTracks: () => [{ kind: "audio" }] } }; + } +} + +let root: Root; +let container: HTMLDivElement; +let originalCreate: typeof URL.createObjectURL; +let originalRevoke: typeof URL.revokeObjectURL; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", false); + vi.stubGlobal("MediaRecorder", FakeRecorder); + vi.stubGlobal("AudioContext", FakeAudioContext); + vi.stubGlobal("requestAnimationFrame", (callback: () => void) => + setTimeout(callback, 0), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + recordedStreams = []; + vi.spyOn(HTMLMediaElement.prototype, "duration", "get").mockReturnValue(6); + vi.spyOn(HTMLMediaElement.prototype, "currentTime", "get").mockReturnValue(6); + vi.spyOn(HTMLMediaElement.prototype, "currentTime", "set").mockImplementation( + () => {}, + ); + vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + Object.defineProperty( + HTMLMediaElement.prototype, + "webkitAudioDecodedByteCount", + { + configurable: true, + get: () => 1024, + }, + ); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + drawImage: vi.fn(), + } as unknown as CanvasRenderingContext2D); + Object.defineProperty(HTMLCanvasElement.prototype, "captureStream", { + configurable: true, + value: () => new FakeStream(), + }); + originalCreate = URL.createObjectURL; + originalRevoke = URL.revokeObjectURL; + URL.createObjectURL = (blob) => + blob instanceof File ? `blob:${blob.name}` : "blob:trimmed"; + URL.revokeObjectURL = vi.fn(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + root.unmount(); + container.remove(); + URL.createObjectURL = originalCreate; + URL.revokeObjectURL = originalRevoke; + Reflect.deleteProperty( + HTMLMediaElement.prototype, + "webkitAudioDecodedByteCount", + ); + Reflect.deleteProperty(HTMLCanvasElement.prototype, "captureStream"); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const button = (label: string) => + Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent === label, + ); + +const selectVideo = async (name: string) => { + await vi.waitFor(() => + expect(container.querySelector("input[type=file]")).not.toBeNull(), + ); + const input = container.querySelector("input[type=file]") as HTMLInputElement; + Object.defineProperty(input, "files", { + value: [new File(["video"], name, { type: "video/mp4" })], + }); + input.dispatchEvent(new Event("change", { bubbles: true })); + await loadPreview(`blob:${name}`); +}; + +const loadPreview = async (src: string) => { + const video = container.querySelector("video") as HTMLVideoElement; + await vi.waitFor(() => expect(video.getAttribute("src")).toBe(src)); + video.dispatchEvent(new Event("loadedmetadata")); + await vi.waitFor(() => expect(button("Trim Video")).toBeDefined()); +}; + +const trim = async () => { + button("Trim Video")?.click(); + await vi.waitFor(() => + expect(button("Download Trimmed Video")).toBeDefined(), + ); +}; + +it.each([ + { + path: "Trim Again", + next: async () => { + button("Trim Again")?.click(); + await loadPreview("blob:first.mp4"); + }, + }, + { + path: "a different video", + next: async () => { + button("Trim Again")?.click(); + await loadPreview("blob:first.mp4"); + button("Choose Different Video")?.click(); + await selectVideo("second.mp4"); + }, + }, +])("keeps the audio when trimming again after $path", async ({ next }) => { + root.render(createElement(TrimmingTool)); + await selectVideo("first.mp4"); + await trim(); + + await next(); + await trim(); + + expect( + recordedStreams.map( + (stream) => stream.tracks.filter(({ kind }) => kind === "audio").length, + ), + ).toEqual([1, 1]); +}); diff --git a/apps/web/components/tools/TrimmingTool.tsx b/apps/web/components/tools/TrimmingTool.tsx index 98614e19be3..143354143d9 100644 --- a/apps/web/components/tools/TrimmingTool.tsx +++ b/apps/web/components/tools/TrimmingTool.tsx @@ -55,6 +55,10 @@ export const TrimmingTool = () => { const videoPreviewRef = useRef(null); const outputVideoRef = useRef(null); const mediaRecorderRef = useRef(null); + const previewAudioRef = useRef<{ + video: HTMLVideoElement; + destination: MediaStreamAudioDestinationNode; + } | null>(null); const recordedChunksRef = useRef([]); const isVideoFile = (file: File): boolean => { @@ -415,16 +419,24 @@ export const TrimmingTool = () => { Boolean((video as any).webkitAudioDecodedByteCount) || Boolean((video as any).audioTracks?.length) ) { - const audioContext = new ( - window.AudioContext || (window as any).webkitAudioContext - )(); - const source = audioContext.createMediaElementSource(video); - const destination = audioContext.createMediaStreamDestination(); - - source.connect(destination); - source.connect(audioContext.destination); + // A media element can only ever feed one MediaElementAudioSourceNode, + // so later trims of the preview reuse the first one. + let previewAudio = previewAudioRef.current; + if (previewAudio?.video !== video) { + const audioContext = new ( + window.AudioContext || (window as any).webkitAudioContext + )(); + const source = audioContext.createMediaElementSource(video); + const destination = audioContext.createMediaStreamDestination(); + + source.connect(destination); + source.connect(audioContext.destination); + previewAudio = { video, destination }; + previewAudioRef.current = previewAudio; + } - const audioTracks = destination.stream.getAudioTracks(); + const audioTracks = + previewAudio.destination.stream.getAudioTracks(); if (audioTracks.length > 0) { audioTracks.forEach((track) => {