diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70bb14815be..e4e17029eab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,6 +144,7 @@ jobs: __tests__/unit/media-comment-playback-cleanup.test.ts __tests__/unit/media-format-converter-selection.test.ts __tests__/unit/media-format-converter.test.ts + __tests__/unit/gif-video-errors.test.ts __tests__/unit/media-player-store-refs.test.ts __tests__/unit/mobile-caps-list.test.ts __tests__/unit/notification-read-state.test.ts diff --git a/apps/web/__tests__/unit/gif-video-errors.test.ts b/apps/web/__tests__/unit/gif-video-errors.test.ts new file mode 100644 index 00000000000..ba6ebd4414e --- /dev/null +++ b/apps/web/__tests__/unit/gif-video-errors.test.ts @@ -0,0 +1,199 @@ +// @vitest-environment jsdom + +import { + act, + type ComponentProps, + createElement, + type PropsWithChildren, +} from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { MediaFormatConverter } from "@/components/tools/MediaFormatConverter"; + +vi.mock("@cap/ui", () => ({ + Button: (props: ComponentProps<"button">) => + createElement("button", { ...props, type: "button" }), +})); +vi.mock("next/link", () => ({ + default: ({ children }: PropsWithChildren) => children, +})); +vi.mock("next/navigation", () => ({ + usePathname: () => "/tools/convert/mp4-to-gif", + useRouter: () => ({ push: vi.fn() }), +})); +vi.mock("@/app/utils/analytics", () => ({ trackEvent: vi.fn() })); +vi.mock("@remotion/media-parser", () => ({ + parseMedia: async () => ({ + durationInSeconds: 1, + dimensions: { width: 160, height: 90 }, + videoCodec: "h265", + }), + mediaParserController: () => ({ abort: vi.fn() }), + hasBeenAborted: () => false, +})); +const encoder = vi.hoisted(() => ({ fail: false, frames: 0 })); +vi.mock("gif.js", () => ({ + default: class { + private finish?: (blob: Blob) => void; + constructor() { + if (encoder.fail) throw new Error("Encoder unavailable"); + } + addFrame() { + encoder.frames++; + } + on(event: string, callback: (blob: Blob) => void) { + if (event === "finished") this.finish = callback; + } + render() { + this.finish?.(new Blob(["GIF89a"], { type: "image/gif" })); + } + }, +})); + +let root: Root; +let container: HTMLDivElement; +let srcEvent: "loadedmetadata" | "error"; +let seekEvent: "seeked" | "error"; +let sourceThrows: boolean; +let videos: HTMLMediaElement[]; +const drawImage = vi.fn(); + +beforeEach(async () => { + encoder.fail = false; + encoder.frames = 0; + srcEvent = "loadedmetadata"; + seekEvent = "seeked"; + sourceThrows = false; + videos = []; + drawImage.mockReset(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("MediaRecorder", { isTypeSupported: () => true }); + vi.stubGlobal( + "URL", + class extends URL { + static createObjectURL = vi.fn((blob: Blob) => + blob.type === "image/gif" ? "blob:output" : "blob:input", + ); + static revokeObjectURL = vi.fn(); + }, + ); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "duration", "get").mockReturnValue(1); + vi.spyOn(HTMLMediaElement.prototype, "src", "set").mockImplementation( + function (this: HTMLMediaElement, value: string) { + videos.push(this); + if (sourceThrows) throw new Error("Video source unavailable"); + this.setAttribute("src", value); + queueMicrotask(() => this.dispatchEvent(new Event(srcEvent))); + }, + ); + vi.spyOn(HTMLMediaElement.prototype, "currentTime", "set").mockImplementation( + function (this: HTMLMediaElement) { + queueMicrotask(() => this.dispatchEvent(new Event(seekEvent))); + }, + ); + vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + drawImage, + } as unknown as CanvasRenderingContext2D); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => + root.render( + createElement(MediaFormatConverter, { + initialConversionPath: "mp4-to-gif", + }), + ), + ); + const input = container.querySelector("input[type=file]"); + if (!(input instanceof HTMLInputElement)) + throw new Error("Missing file input"); + Object.defineProperty(input, "files", { + value: [new File(["media"], "clip.mp4", { type: "video/mp4" })], + }); + await act(async () => + input.dispatchEvent(new Event("change", { bubbles: true })), + ); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +async function convert() { + const button = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Convert to GIF", + ); + if (!button) throw new Error("Missing conversion action"); + await act(async () => button.click()); +} +async function expectFailure(message: string) { + await vi.waitFor(() => expect(container.textContent).toContain(message)); + expect(container.textContent).not.toContain("Converting..."); + expect(container.textContent).not.toContain("Download GIF"); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:input"); + expect(videos[0]?.hasAttribute("src")).toBe(false); + expect(HTMLMediaElement.prototype.load).toHaveBeenCalled(); +} + +it.each(["metadata", "seek"])( + "surfaces a video %s error and allows retry", + async (phase) => { + if (phase === "metadata") srcEvent = "error"; + else seekEvent = "error"; + await convert(); + await expectFailure( + "Your browser could not read this video for GIF conversion.", + ); + srcEvent = "loadedmetadata"; + seekEvent = "seeked"; + await convert(); + await vi.waitFor(() => + expect(container.textContent).toContain("Download GIF"), + ); + }, +); + +it("releases the input when playback rejects", async () => { + vi.mocked(HTMLMediaElement.prototype.play).mockRejectedValueOnce( + new Error("Playback unavailable"), + ); + await convert(); + await expectFailure("Playback unavailable"); +}); + +it("releases the input when the GIF encoder cannot initialize", async () => { + encoder.fail = true; + await convert(); + await expectFailure("Encoder unavailable"); +}); + +it("surfaces a frame drawing failure instead of leaving a pending seek", async () => { + drawImage.mockImplementationOnce(() => { + throw new Error("Frame could not be drawn"); + }); + await convert(); + await expectFailure("Frame could not be drawn"); +}); + +it("releases the input when assigning the video source throws", async () => { + sourceThrows = true; + await convert(); + await expectFailure("Video source unavailable"); +}); + +it("keeps successful frame capture and the downloadable GIF", async () => { + await convert(); + await vi.waitFor(() => + expect(container.textContent).toContain("Download GIF"), + ); + expect(encoder.frames).toBe(15); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:input"); + expect(URL.revokeObjectURL).not.toHaveBeenCalledWith("blob:output"); +}); diff --git a/apps/web/__tests__/unit/media-format-converter.test.ts b/apps/web/__tests__/unit/media-format-converter.test.ts index c4d26498e14..37744d60a0a 100644 --- a/apps/web/__tests__/unit/media-format-converter.test.ts +++ b/apps/web/__tests__/unit/media-format-converter.test.ts @@ -157,6 +157,7 @@ it.each([ }); vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(); vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ drawImage: vi.fn(), } as unknown as CanvasRenderingContext2D); diff --git a/apps/web/components/tools/MediaFormatConverter.tsx b/apps/web/components/tools/MediaFormatConverter.tsx index 16a45e44f99..0a685872aaa 100644 --- a/apps/web/components/tools/MediaFormatConverter.tsx +++ b/apps/web/components/tools/MediaFormatConverter.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { trackEvent } from "@/app/utils/analytics"; +import { waitForVideoEvent } from "@/lib/wait-for-video-event"; export const SUPPORTED_FORMATS = { video: ["mp4", "webm", "mov", "avi", "mkv"], @@ -433,6 +434,8 @@ export const MediaFormatConverter = ({ }; const convertVideoToGif = async (inputFile: File): Promise => { + const videoElement = document.createElement("video"); + let videoUrl: string | undefined; try { const parser = await import("@remotion/media-parser"); const _webcodecs = await import("@remotion/webcodecs"); @@ -487,13 +490,12 @@ export const MediaFormatConverter = ({ const GifModule = await import("gif.js"); const GIF = GifModule.default; - const videoElement = document.createElement("video"); videoElement.muted = true; videoElement.playsInline = true; - videoElement.src = URL.createObjectURL(inputFile); - - await new Promise((resolve) => { - videoElement.onloadedmetadata = () => resolve(null); + videoUrl = URL.createObjectURL(inputFile); + const sourceUrl = videoUrl; + await waitForVideoEvent(videoElement, "loadedmetadata", () => { + videoElement.src = sourceUrl; }); const videoDuration = videoElement.duration; @@ -520,14 +522,11 @@ export const MediaFormatConverter = ({ }); const captureFrame = async (time: number): Promise => { - return new Promise((resolve) => { + await waitForVideoEvent(videoElement, "seeked", () => { videoElement.currentTime = time; - videoElement.onseeked = () => { - ctx.drawImage(videoElement, 0, 0, targetWidth, targetHeight); - gifEncoder.addFrame(canvas, { delay: frameDelay, copy: true }); - resolve(); - }; }); + ctx.drawImage(videoElement, 0, 0, targetWidth, targetHeight); + gifEncoder.addFrame(canvas, { delay: frameDelay, copy: true }); }; gifEncoder.on("progress", (progress: number) => { @@ -557,8 +556,6 @@ export const MediaFormatConverter = ({ gifEncoder.render(); }); - URL.revokeObjectURL(videoElement.src); - const gifUrl = URL.createObjectURL(gifBlob); setOutputUrl(gifUrl); setProgress(100); @@ -591,6 +588,13 @@ export const MediaFormatConverter = ({ } throw error; + } finally { + if (videoUrl) { + URL.revokeObjectURL(videoUrl); + videoElement.pause(); + videoElement.removeAttribute("src"); + videoElement.load(); + } } }; diff --git a/apps/web/lib/wait-for-video-event.ts b/apps/web/lib/wait-for-video-event.ts new file mode 100644 index 00000000000..7418583cd9d --- /dev/null +++ b/apps/web/lib/wait-for-video-event.ts @@ -0,0 +1,30 @@ +export function waitForVideoEvent( + video: HTMLVideoElement, + event: "loadedmetadata" | "seeked", + start: () => void, +) { + return new Promise((resolve, reject) => { + const cleanup = () => { + video.removeEventListener(event, onReady); + video.removeEventListener("error", onError); + }; + const onReady = () => { + cleanup(); + resolve(); + }; + const onError = () => { + cleanup(); + reject( + new Error("Your browser could not read this video for GIF conversion."), + ); + }; + video.addEventListener(event, onReady); + video.addEventListener("error", onError); + try { + start(); + } catch (error) { + cleanup(); + reject(error); + } + }); +}