ScreenshotNeo

BlogHow-to

How to Capture Multiple Screenshots from an HTML5 Video with JavaScript

Capture video frames at timestamps with JavaScript and canvas, including seeking, CORS, downloads, troubleshooting, and ScreenshotNeo.

By the ScreenshotNeo team30 September 20266 min read

How to Capture Multiple Screenshots from an HTML5 Video with JavaScript

Direct answer: Use an HTML5 <video> as a canvas source. For each timestamp, set video.currentTime, await seeked, wait for a video frame when supported, draw with ctx.drawImage(), then export with canvas.toBlob(). Capture timestamps serially.

1. Minimal working example

<video id='video' preload='metadata' crossorigin='anonymous' src='/media/demo.mp4'></video>
<canvas id='canvas' hidden></canvas><div id='shots'></div>
<script>
const video=document.querySelector('#video'), canvas=document.querySelector('#canvas'), shots=document.querySelector('#shots');
function once(target,name){return new Promise((resolve,reject)=>{const ok=e=>{cleanup();resolve(e)},bad=()=>{cleanup();reject(target.error||new Error('Video failed to load'))};const cleanup=()=>{target.removeEventListener(name,ok);target.removeEventListener('error',bad)};target.addEventListener(name,ok,{once:true});target.addEventListener('error',bad,{once:true})})}
async function captureAt(seconds){if(video.readyState<HTMLMediaElement.HAVE_METADATA)await once(video,'loadedmetadata');if(!video.videoWidth||!video.videoHeight)throw Error('Video dimensions unavailable');canvas.width=video.videoWidth;canvas.height=video.videoHeight;const seek=once(video,'seeked');video.currentTime=seconds;if(video.seeking)await seek;if('requestVideoFrameCallback' in video)await new Promise(r=>video.requestVideoFrameCallback(r));const ctx=canvas.getContext('2d');if(!ctx)throw Error('Canvas 2D context unavailable');ctx.drawImage(video,0,0,canvas.width,canvas.height);return new Promise((resolve,reject)=>canvas.toBlob(b=>b?resolve(b):reject(Error('Encoding failed')),'image/png'))}
async function captureMany(times){const out=[];for(const seconds of times){const blob=await captureAt(seconds),url=URL.createObjectURL(blob);out.push({seconds,blob,url});const img=document.createElement('img');img.src=url;img.alt=`Frame at ${seconds} seconds`;img.width=240;shots.append(img)}return out}
captureMany([0,2.5,5,10]).catch(console.error);
</script>

currentTime is in seconds and starts a seek; it does not mean the frame is ready. seeked signals completion. See MDN’s currentTime, seeked, and requestVideoFrameCallback references.

Each timestamp is sought, drawn to canvas, and exported as an image.
Each timestamp is sought, drawn to canvas, and exported as an image.

2. Production helper with validation and timeouts

function waitFor(target,name,ms=15000){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>{clean();reject(Error(`${name} timed out`))},ms);const ok=e=>{clean();resolve(e)},bad=()=>{clean();reject(target.error||Error('Media error'))};function clean(){clearTimeout(timer);target.removeEventListener(name,ok);target.removeEventListener('error',bad)}target.addEventListener(name,ok,{once:true});target.addEventListener('error',bad,{once:true})})}
function validate(video,t){if(!Number.isFinite(t)||t<0)throw RangeError('Timestamp must be non-negative');if(Number.isFinite(video.duration)&&t>video.duration)throw RangeError('Timestamp exceeds duration');if(!Number.isFinite(video.duration)&&video.seekable.length){let ok=false;for(let i=0;i<video.seekable.length;i++)ok ||= t>=video.seekable.start(i)&&t<=video.seekable.end(i);if(!ok)throw RangeError('Timestamp outside seekable range')}}
async function captureAt(video,canvas,t,{type='image/png',quality,timeoutMs=15000}={}){if(video.readyState<HTMLMediaElement.HAVE_METADATA)await waitFor(video,'loadedmetadata',timeoutMs);validate(video,t);canvas.width=video.videoWidth;canvas.height=video.videoHeight;const seeking=waitFor(video,'seeked',timeoutMs);video.currentTime=t;if(video.seeking)await seeking;if('requestVideoFrameCallback' in video)await Promise.race([new Promise(r=>video.requestVideoFrameCallback(r)),new Promise((_,j)=>setTimeout(()=>j(Error('Frame timeout')),timeoutMs))]);else if(video.readyState<HTMLMediaElement.HAVE_CURRENT_DATA)await waitFor(video,'loadeddata',timeoutMs);const ctx=canvas.getContext('2d');ctx.drawImage(video,0,0,canvas.width,canvas.height);return new Promise((r,j)=>canvas.toBlob(b=>b?r(b):j(Error('Encoding failed')),type,quality))}
async function captureMany(video,canvas,times,options){const out=[];for(const t of [...new Set(times)])out.push({seconds:t,blob:await captureAt(video,canvas,t,options)});return out}

Output choices

  • image/png is lossless and suits text or diagrams.
  • image/jpeg with quality around 0.85 suits photographic frames.
  • image/webp can reduce size where supported.
  • Use video.videoWidth/video.videoHeight for native pixels; set explicit dimensions only when scaling is intended.

3. Timing, seek ranges, and live media

Await loadedmetadata before using duration or dimensions. A file normally has finite duration, while a live stream may expose only a moving seekable range. Timelines need not start at zero, and a seek can land on the nearest codec-supported position rather than the exact requested instant. Expired live segments cannot be recovered. Never issue overlapping currentTime assignments; serialize the queue.

4. Cross-origin video and canvas security

Set crossorigin='anonymous' before src, and have the media host return Access-Control-Allow-Origin for your page. Otherwise the canvas is tainted: toBlob(), toDataURL(), and pixel reads throw SecurityError. JavaScript cannot override server CORS; use an authorized same-origin proxy only when you control the media. See MDN’s CORS canvas guide.

5. Download and display many frames

function addDownload(blob,seconds,container){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=`frame-${seconds.toFixed(3)}.png`;a.textContent=`Download frame at ${seconds}s`;container.append(a,document.createElement('br'));}
const frames=await captureMany(video,canvas,[1,4,9],{type:'image/jpeg',quality:.9});frames.forEach(f=>addDownload(f.blob,f.seconds,document.querySelector('#downloads')));

Prefer Blobs and object URLs for files. toDataURL() creates a larger string and is best for small previews. Revoke URLs, cap full-resolution frame counts, and process batches to control memory.

6. Browser support

currentTime and seeked are widely available. MDN labels requestVideoFrameCallback() Baseline 2024 and warns about older browsers. Feature-detect it; otherwise await seeked, verify readyState >= HAVE_CURRENT_DATA or wait for loadeddata, then draw.

7. Troubleshooting

Symptom Cause Fix
Blank or old frame Draw happened before readiness Await seeked and a frame callback or loadeddata.
SecurityError Tainted canvas Configure CORS and set crossOrigin before src.
Seek timeout Unreachable time or stalled media Validate duration/seekable, add timeout, handle error.
Zero dimensions Metadata not loaded Await loadedmetadata.
Wrong timestamp Concurrent seeks Use a serial loop.
Live target missing Segment left seekable window Capture while it remains seekable.

8. Performance and reliability

  • Each seek may require network I/O and keyframe decoding; fewer timestamps and smaller output dimensions reduce work.
  • Reuse one video and canvas, deduplicate timestamps, and process serially.
  • Use timeouts and retry transient media failures, but do not retry permanently unavailable live times.
  • No browser API promises exact frame precision for every codec; verify output when precision matters.

9. Or skip the browser setup

ScreenshotNeo is a website screenshot API. It accepts consent banners and removes 60+ known consent platforms, newsletter popups, and chat widgets before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; X-Page-Verdict and X-Billed headers identify the result. Its MCP server includes take_screenshot, get_page_info, and capture_pdf for AI agents.

Consent banners, popups, and chat widgets can be removed before a web capture.
Consent banners, popups, and chat widgets can be removed before a web capture.

See the ScreenshotNeo docs.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Free includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

10. FAQ

Can I capture without playing?

Usually yes; seeking and drawing do not require continuous playback.

Why is the frame not exact?

currentTime is approximate and codecs seek to supported positions.

Which export method is better?

Use toBlob() for files and batches; use toDataURL() for a small inline preview.

Can JavaScript bypass CORS?

No. The media server must grant access or an authorized same-origin service must provide it.