Skip to content

fix(web): detect cross-origin support from probe redirect, fix broken thumbnail tooltip#1730

Merged
richiemcilroy merged 1 commit intoCapSoftware:mainfrom
mstublefield:fix/r2-thumbnail-scrub-cors-detection
Apr 13, 2026
Merged

fix(web): detect cross-origin support from probe redirect, fix broken thumbnail tooltip#1730
richiemcilroy merged 1 commit intoCapSoftware:mainfrom
mstublefield:fix/r2-thumbnail-scrub-cors-detection

Conversation

@mstublefield
Copy link
Copy Markdown
Contributor

@mstublefield mstublefield commented Apr 13, 2026

Summary

Cap Pro users with custom R2/S3 buckets see a red "Error" thumbnail when hovering over the video progress bar. Their CORS is configured correctly — the fetch probe follows a cross-origin redirect and the browser enforces CORS on the response — but detectCrossOriginSupport ignores the runtime signal and returns false based on a hostname blocklist, preventing crossOrigin="anonymous" from being set on the <video> element.

  • Runtime CORS detection: Use response.redirected from the probe as a definitive signal that CORS works, short-circuiting the hostname blocklist for redirected responses
  • Extract canvas capture helper: New captureVideoFrameDataUrl function returns undefined on all failure paths instead of red placeholder URLs (placeholder.pics/.../Error)
  • Graceful tooltip degradation: Widen MediaPlayerSeek.tooltipThumbnailSrc type and add null guard so the tooltip falls back to time-only display when capture fails

Root cause (HAR-verified)

The probe fetch to /api/playlist returns 302 redirecting to a presigned R2 URL. The browser enforces CORS on the cross-origin response — Access-Control-Allow-Origin is present and correct. But detectCrossOriginSupport checks hostname.includes("r2.cloudflarestorage.com") → returns falsecrossOrigin attribute omitted → canvas taints on drawImagetoDataURL throws SecurityError → catch returns red placeholder URL.

Defense in depth

  1. If CORS probe fails → no video renders → no thumbnail attempt
  2. If supportsCrossOrigin is false → tooltipThumbnailSrc is undefined → no canvas capture attempted
  3. If canvas capture fails anyway → returns undefined (not a red error image)
  4. If getThumbnail receives undefined → returns null → tooltip shows time only

Pre-existing issue (out of scope)

generateVideoFrameThumbnail ignores its time parameter and always captures video.currentTime. Proper scrub previews need server-side sprite sheets + WebVTT — separate, larger future PR.

Test plan

  • pnpm --filter=@cap/web test — 783/783 pass (10 new/updated test cases)
  • pnpm typecheck — no new errors (pre-existing PageProps issues unrelated)
  • pnpm lint — no errors in changed files
  • pnpm format — clean
  • Local smoke test: run pnpm dev:web, upload video, hover progress bar — no red "Error"
  • R2 integration test: point local dev at real R2 bucket with CORS configured, verify thumbnail renders

🤖 Generated with Claude Code

Greptile Summary

This PR fixes a bug where Cap Pro users with custom R2/S3 buckets get a red "Error" thumbnail on video hover because detectCrossOriginSupport was using a hostname blocklist that ignored a successful CORS-validated redirect. The fix uses response.redirected from the probe fetch as a definitive runtime signal to bypass the blocklist, and also extracts canvas capture into a testable helper that returns undefined (rather than a red placeholder URL) on all failure paths.

The logic is correct: if the probe fetch follows a cross-origin redirect and still returns a successful response, the browser has already validated that CORS headers are present, so crossOrigin=\"anonymous\" on the <video> element will work.

Confidence Score: 5/5

Safe to merge — the fix is logically sound, well-tested, and only affects thumbnail behaviour for Pro users with custom buckets.

All findings are P2 (pre-existing acknowledged limitation and a style note about unused parameter). No regressions introduced; the CORS detection logic correctly uses response.redirected as a definitive runtime signal, and the canvas extraction refactor removes a UX-visible red error URL. 783 tests passing, types clean.

No files require special attention.

Important Files Changed

Filename Overview
apps/web/app/s/[videoId]/_components/playback-source.ts Core fix: detectCrossOriginSupport now accepts probeWasRedirected and short-circuits to true, correctly enabling cross-origin mode when the probe fetch confirmed a valid CORS-redirected response.
apps/web/app/s/[videoId]/_components/video-frame-thumbnail.ts New extracted helper returns undefined on all failure paths instead of placeholder error URLs; includes readyState < 2 guard and dependency injection for testability.
apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx Replaces inline canvas/placeholder logic with captureVideoFrameDataUrl; condition for enabling thumbnails correctly changed to resolvedSrc.data?.supportsCrossOrigin for precise cross-origin gating.
apps/web/app/s/[videoId]/_components/video/media-player.tsx Type widened for tooltipThumbnailSrc callback return to `string
apps/web/tests/unit/playback-source.test.ts Tests updated with redirected: true on mock response and new cases for the probeWasRedirected parameter; S3 URL assertion correctly updated from false to true.
apps/web/tests/unit/video-frame-thumbnail.test.ts New test file with comprehensive coverage of all captureVideoFrameDataUrl failure paths and the happy path, using dependency injection for canvas and video mocks.

Sequence Diagram

sequenceDiagram
    participant Browser
    participant ProbeAPI as "/api/playlist (same-origin)"
    participant R2 as "R2/S3 Bucket (cross-origin)"
    participant Canvas

    Browser->>ProbeAPI: fetch(url, range bytes=0-0)
    ProbeAPI-->>Browser: 302 redirect to presigned R2 URL
    Browser->>R2: follow redirect in cors mode with Origin header
    R2-->>Browser: 206 Partial + Access-Control-Allow-Origin OK

    Note over Browser: response.redirected = true

    Browser->>Browser: detectCrossOriginSupport(url, true) returns true
    Note over Browser: video element gets crossOrigin=anonymous

    Browser->>Canvas: drawImage(video, 0, 0, w, h)
    Canvas-->>Browser: toDataURL returns jpeg data URL
Loading
Prompt To Fix All 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.

---

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.

Reviews (1): Last reviewed commit: "fix(web): detect cross-origin support fr..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

… thumbnail tooltip

Cap Pro users with custom R2/S3 buckets saw a red "Error" thumbnail on
progress bar hover. The hostname heuristic in detectCrossOriginSupport
returned false for R2/S3 URLs even when the fetch probe proved CORS
works via a successful cross-origin redirect. This caused the <video>
element to omit crossOrigin="anonymous", tainting the canvas on
drawImage and throwing SecurityError on toDataURL.

Fix: use response.redirected as a runtime CORS signal (if the browser
followed a same-origin→cross-origin redirect in cors mode and the
probe succeeded, CORS is definitively working). Extract canvas capture
into a pure helper that returns undefined on all failure paths instead
of red placeholder URLs. Add explicit null guard in MediaPlayerSeek so
the tooltip degrades gracefully to time-only when capture fails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment on lines +406 to 409
(_time: number): string | undefined =>
captureVideoFrameDataUrl({ video: videoRef.current }),
[],
);
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.

Comment on lines +66 to +70
export function detectCrossOriginSupport(
url: string,
probeWasRedirected = false,
): boolean {
if (probeWasRedirected) return true;
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.

@mstublefield
Copy link
Copy Markdown
Contributor Author

Tested locally as working.

Screenshot 2026-04-13 at 11 08 52 AM

@richiemcilroy richiemcilroy merged commit 64e5f01 into CapSoftware:main Apr 13, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants