-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix(web): detect cross-origin support from probe redirect, fix broken thumbnail tooltip #1730
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,7 +63,11 @@ async function probePlaybackSource( | |
| } | ||
| } | ||
|
|
||
| export function detectCrossOriginSupport(url: string): boolean { | ||
| export function detectCrossOriginSupport( | ||
| url: string, | ||
| probeWasRedirected = false, | ||
| ): boolean { | ||
| if (probeWasRedirected) return true; | ||
|
Comment on lines
+66
to
+70
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When This is a narrow remaining gap: it affects users whose Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/web/app/s/[videoId]/_components/playback-source.ts
Line: 66-70
Comment:
**Direct S3/R2 URLs without a redirect still blocked**
When `videoSrc` points directly at a presigned S3 or R2 URL (no initial redirect through `/api/playlist`), `response.redirected` is `false`, and the hostname blocklist still returns `false`, so thumbnails remain disabled even if CORS headers are correctly set on that bucket.
This is a narrow remaining gap: it affects users whose `videoSrc` is a bare presigned URL rather than a same-origin proxy. Not a regression introduced here, but worth a follow-up when expanding cross-origin support beyond the proxy-redirect path.
How can I resolve this? If you propose a fix, please make it concise. |
||
| try { | ||
| const hostname = new URL(url, "https://cap.so").hostname; | ||
| const isR2OrS3 = | ||
|
|
@@ -135,7 +139,8 @@ export async function resolvePlaybackSource({ | |
| url: rawResult.url, | ||
| type: "raw", | ||
| supportsCrossOrigin: | ||
| enableCrossOrigin && detectCrossOriginSupport(rawResult.url), | ||
| enableCrossOrigin && | ||
| detectCrossOriginSupport(rawResult.url, rawResult.response.redirected), | ||
| }; | ||
| }; | ||
|
|
||
|
|
@@ -150,7 +155,8 @@ export async function resolvePlaybackSource({ | |
| url: mp4Result.url, | ||
| type: "mp4", | ||
| supportsCrossOrigin: | ||
| enableCrossOrigin && detectCrossOriginSupport(mp4Result.url), | ||
| enableCrossOrigin && | ||
| detectCrossOriginSupport(mp4Result.url, mp4Result.response.redirected), | ||
| }; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| export interface CaptureVideoFrameDeps { | ||
| video: HTMLVideoElement | null; | ||
| createCanvas?: () => 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_timeparameter is permanently unusedgenerateVideoFrameThumbnailignores the hoveredtimeentirely and always capturesvideoRef.current(the current playback frame). The underscore prefix correctly signals the intent, and this is acknowledged in the PR description as a pre-existing issue. The consequence is that hover previews will always show the live playback frame, not the position being hovered over.For future reference, when proper scrub previews are implemented (server-side sprite sheets + WebVTT), the parameter name and implementation will need to change.
Prompt To Fix With AI