diff --git a/apps/web/__tests__/unit/playback-source.test.ts b/apps/web/__tests__/unit/playback-source.test.ts index 7e40087495..863db2e19e 100644 --- a/apps/web/__tests__/unit/playback-source.test.ts +++ b/apps/web/__tests__/unit/playback-source.test.ts @@ -32,7 +32,7 @@ function createResponse( } describe("detectCrossOriginSupport", () => { - it("disables cross-origin for S3 and R2 URLs", () => { + it("disables cross-origin for S3 and R2 URLs when not redirected", () => { expect( detectCrossOriginSupport( "https://cap-assets.r2.cloudflarestorage.com/video.mp4", @@ -45,6 +45,30 @@ describe("detectCrossOriginSupport", () => { ).toBe(false); expect(detectCrossOriginSupport("/api/playlist?videoType=mp4")).toBe(true); }); + + it("enables cross-origin for S3/R2 URLs when probe was redirected", () => { + expect( + detectCrossOriginSupport( + "https://cap-assets.r2.cloudflarestorage.com/video.mp4", + true, + ), + ).toBe(true); + expect( + detectCrossOriginSupport( + "https://bucket.s3.eu-west-2.amazonaws.com/video.mp4", + true, + ), + ).toBe(true); + }); + + it("falls back to hostname heuristic when not redirected", () => { + expect( + detectCrossOriginSupport( + "https://cap-assets.r2.cloudflarestorage.com/video.mp4", + false, + ), + ).toBe(false); + }); }); describe("canPlayRawContentType", () => { @@ -103,7 +127,7 @@ describe("resolvePlaybackSource", () => { expect(result).toEqual({ url: "https://bucket.s3.amazonaws.com/result.mp4", type: "mp4", - supportsCrossOrigin: false, + supportsCrossOrigin: true, }); }); diff --git a/apps/web/__tests__/unit/video-frame-thumbnail.test.ts b/apps/web/__tests__/unit/video-frame-thumbnail.test.ts new file mode 100644 index 0000000000..5925ac2dbb --- /dev/null +++ b/apps/web/__tests__/unit/video-frame-thumbnail.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type CaptureVideoFrameDeps, + captureVideoFrameDataUrl, +} from "@/app/s/[videoId]/_components/video-frame-thumbnail"; + +function createMockCanvas(options?: { + contextReturnsNull?: boolean; + drawImageThrows?: boolean; + toDataURLThrows?: boolean; +}) { + const ctx = { + drawImage: options?.drawImageThrows + ? vi.fn(() => { + throw new DOMException("Tainted canvas", "SecurityError"); + }) + : vi.fn(), + }; + + return { + canvas: { + width: 0, + height: 0, + getContext: options?.contextReturnsNull + ? vi.fn().mockReturnValue(null) + : vi.fn().mockReturnValue(ctx), + toDataURL: options?.toDataURLThrows + ? vi.fn(() => { + throw new DOMException("Tainted canvas", "SecurityError"); + }) + : vi.fn().mockReturnValue("data:image/jpeg;base64,abc123"), + } as unknown as HTMLCanvasElement, + ctx, + }; +} + +function createMockVideo(readyState = 3): HTMLVideoElement { + return { readyState } as unknown as HTMLVideoElement; +} + +describe("captureVideoFrameDataUrl", () => { + it("returns undefined when video is null", () => { + const result = captureVideoFrameDataUrl({ video: null }); + expect(result).toBeUndefined(); + }); + + it("returns undefined when video.readyState < 2", () => { + const result = captureVideoFrameDataUrl({ + video: createMockVideo(1), + }); + expect(result).toBeUndefined(); + }); + + it("returns undefined when the 2D context is null", () => { + const { canvas } = createMockCanvas({ contextReturnsNull: true }); + const result = captureVideoFrameDataUrl({ + video: createMockVideo(), + createCanvas: () => canvas, + }); + expect(result).toBeUndefined(); + }); + + it("returns undefined when drawImage throws (tainted canvas)", () => { + const { canvas } = createMockCanvas({ drawImageThrows: true }); + const result = captureVideoFrameDataUrl({ + video: createMockVideo(), + createCanvas: () => canvas, + }); + expect(result).toBeUndefined(); + }); + + it("returns undefined when toDataURL throws (tainted canvas)", () => { + const { canvas } = createMockCanvas({ toDataURLThrows: true }); + const result = captureVideoFrameDataUrl({ + video: createMockVideo(), + createCanvas: () => canvas, + }); + expect(result).toBeUndefined(); + }); + + it("returns a data URL on the happy path", () => { + const { canvas, ctx } = createMockCanvas(); + const video = createMockVideo(); + const result = captureVideoFrameDataUrl({ + video, + createCanvas: () => canvas, + }); + expect(result).toBe("data:image/jpeg;base64,abc123"); + expect(ctx.drawImage).toHaveBeenCalledWith(video, 0, 0, 224, 128); + expect(canvas.toDataURL).toHaveBeenCalledWith("image/jpeg", 0.8); + }); + + it("respects custom width and height", () => { + const { canvas, ctx } = createMockCanvas(); + const video = createMockVideo(); + captureVideoFrameDataUrl({ + video, + createCanvas: () => canvas, + width: 320, + height: 180, + }); + expect(canvas.width).toBe(320); + expect(canvas.height).toBe(180); + expect(ctx.drawImage).toHaveBeenCalledWith(video, 0, 0, 320, 180); + }); + + it("uses the injected createCanvas factory", () => { + const { canvas } = createMockCanvas(); + const factory = vi.fn().mockReturnValue(canvas); + captureVideoFrameDataUrl({ + video: createMockVideo(), + createCanvas: factory, + }); + expect(factory).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index 8f8e9c6373..32afbddfc9 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -45,6 +45,7 @@ import { MediaPlayerVolumeIndicator, } from "./video/media-player"; import { Tooltip, TooltipContent, TooltipTrigger } from "./video/tooltip"; +import { captureVideoFrameDataUrl } from "./video-frame-thumbnail"; const { circumference } = getProgressCircleConfig(); @@ -402,29 +403,9 @@ export function CapVideoPlayer({ ]); const generateVideoFrameThumbnail = useCallback( - (time: number): string => { - const video = videoRef.current; - - if (!video) { - return `https://placeholder.pics/svg/224x128/1f2937/ffffff/Loading ${Math.floor(time)}s`; - } - - const canvas = document.createElement("canvas"); - canvas.width = 224; - canvas.height = 128; - const ctx = canvas.getContext("2d"); - - if (ctx) { - try { - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); - return canvas.toDataURL("image/jpeg", 0.8); - } catch (_error) { - return `https://placeholder.pics/svg/224x128/dc2626/ffffff/Error`; - } - } - return `https://placeholder.pics/svg/224x128/dc2626/ffffff/Error`; - }, - [videoRef.current], + (_time: number): string | undefined => + captureVideoFrameDataUrl({ video: videoRef.current }), + [], ); const isUploadFailed = uploadProgress?.status === "failed"; @@ -756,7 +737,7 @@ export function CapVideoPlayer({ HTMLCanvasElement; + width?: number; + height?: number; +} + +export function captureVideoFrameDataUrl( + deps: CaptureVideoFrameDeps, +): string | undefined { + const { video, width = 224, height = 128 } = deps; + if (!video) return undefined; + if (video.readyState < 2) return undefined; + const createCanvas = + deps.createCanvas ?? (() => document.createElement("canvas")); + const canvas = createCanvas(); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) return undefined; + try { + ctx.drawImage(video, 0, 0, width, height); + return canvas.toDataURL("image/jpeg", 0.8); + } catch { + return undefined; + } +} diff --git a/apps/web/app/s/[videoId]/_components/video/media-player.tsx b/apps/web/app/s/[videoId]/_components/video/media-player.tsx index 2f91afb27e..0bd0124112 100644 --- a/apps/web/app/s/[videoId]/_components/video/media-player.tsx +++ b/apps/web/app/s/[videoId]/_components/video/media-player.tsx @@ -1535,7 +1535,7 @@ interface MediaPlayerSeekProps withoutChapter?: boolean; withoutTooltip?: boolean; fallbackDuration?: number | null; - tooltipThumbnailSrc?: string | ((time: number) => string); + tooltipThumbnailSrc?: string | ((time: number) => string | undefined | null); tooltipTimeVariant?: "current" | "progress"; tooltipSideOffset?: number; tooltipCollisionBoundary?: Element | Element[]; @@ -1703,6 +1703,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { typeof tooltipThumbnailSrc === "function" ? tooltipThumbnailSrc(time) : tooltipThumbnailSrc; + if (!src) return null; return { src, coords: null }; }