Skip to content
Merged
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
28 changes: 26 additions & 2 deletions apps/web/__tests__/unit/playback-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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", () => {
Expand Down Expand Up @@ -103,7 +127,7 @@ describe("resolvePlaybackSource", () => {
expect(result).toEqual({
url: "https://bucket.s3.amazonaws.com/result.mp4",
type: "mp4",
supportsCrossOrigin: false,
supportsCrossOrigin: true,
});
});

Expand Down
116 changes: 116 additions & 0 deletions apps/web/__tests__/unit/video-frame-thumbnail.test.ts
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);
});
});
29 changes: 5 additions & 24 deletions apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 }),
[],
);
Comment on lines +406 to 409
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _time parameter is permanently unused

generateVideoFrameThumbnail ignores the hovered time entirely and always captures videoRef.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
This is a comment left during a code review.
Path: apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx
Line: 406-409

Comment:
**`_time` parameter is permanently unused**

`generateVideoFrameThumbnail` ignores the hovered `time` entirely and always captures `videoRef.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.

How can I resolve this? If you propose a fix, please make it concise.


const isUploadFailed = uploadProgress?.status === "failed";
Expand Down Expand Up @@ -756,7 +737,7 @@ export function CapVideoPlayer({
<MediaPlayerSeek
fallbackDuration={playerDuration}
tooltipThumbnailSrc={
isMobile || !resolvedSrc.isSuccess
isMobile || !resolvedSrc.data?.supportsCrossOrigin
? undefined
: generateVideoFrameThumbnail
}
Expand Down
12 changes: 9 additions & 3 deletions apps/web/app/s/[videoId]/_components/playback-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Prompt To Fix With AI
This 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 =
Expand Down Expand Up @@ -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),
};
};

Expand All @@ -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),
};
}

Expand Down
27 changes: 27 additions & 0 deletions apps/web/app/s/[videoId]/_components/video-frame-thumbnail.ts
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;
}
}
3 changes: 2 additions & 1 deletion apps/web/app/s/[videoId]/_components/video/media-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -1703,6 +1703,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) {
typeof tooltipThumbnailSrc === "function"
? tooltipThumbnailSrc(time)
: tooltipThumbnailSrc;
if (!src) return null;
return { src, coords: null };
}

Expand Down
Loading