Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
183 changes: 183 additions & 0 deletions apps/web/__tests__/unit/trimming-tool-audio.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>) =>
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<HTMLMediaElement>();
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]);
});
30 changes: 21 additions & 9 deletions apps/web/components/tools/TrimmingTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
const videoPreviewRef = useRef<HTMLVideoElement>(null);
const outputVideoRef = useRef<HTMLVideoElement | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const previewAudioRef = useRef<{
video: HTMLVideoElement;
destination: MediaStreamAudioDestinationNode;
} | null>(null);
const recordedChunksRef = useRef<Blob[]>([]);

const isVideoFile = (file: File): boolean => {
Expand Down Expand Up @@ -412,19 +416,27 @@
try {
if (
(video as any).mozHasAudio ||
Boolean((video as any).webkitAudioDecodedByteCount) ||

Check notice on line 419 in apps/web/components/tools/TrimmingTool.tsx

View workflow job for this annotation

GitHub Actions / Lint (Biome)

lint/complexity/noExtraBooleanCast

Avoid redundant `Boolean` call
Boolean((video as any).audioTracks?.length)

Check notice on line 420 in apps/web/components/tools/TrimmingTool.tsx

View workflow job for this annotation

GitHub Actions / Lint (Biome)

lint/complexity/noExtraBooleanCast

Avoid redundant `Boolean` call
) {
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) => {
Expand Down
Loading