Category: Datasets

Open video/audio datasets and how to build multimodal training sets from YouTube.

  • How to Build a YouTube Video Dataset for Multimodal Training

    Downloading a few thousand YouTube videos is a script. Building a YouTube video dataset that a video-LLM, vision-language or speech model can train on (filtered, deduplicated, captioned, sharded, and traceable back to every source) is a pipeline. This article walks through that pipeline stage by stage, with the yt-dlp flags, open-source tools and published dataset recipes that each stage relies on, plus a runnable metadata script and a sample manifest record.

    TL;DR

    • Metadata first. Enumerate IDs with yt-dlp --flat-playlist, fetch full metadata with --skip-download, and filter on duration, license, language and resolution before you spend any bandwidth on video bytes.
    • The YouTube Data API is for enrichment, not discovery. The default allocation is 100 search.list calls per day plus 10,000 units for other endpoints. videos.list costs 1 unit.
    • Make the download stage idempotent. Use --download-archive, a job queue keyed by video ID, and one proxy session per worker.
    • Processing follows published recipes: shot detection (PySceneDetect), clip stitching or splitting, near-duplicate removal, motion/OCR/aesthetic filters (as in Stable Video Diffusion), recaptioning (as in Panda-70M), and ASR with Whisper.
    • Store samples as WebDataset tar shards, keep metadata in Parquet/JSONL, put a sha256 on every object, and record lineage (video ID, license, collection time) per sample so takedowns can be applied.

    The pipeline at a glance

      seeds (keywords, channels, playlists, existing ID lists)
         |
         v
    [1] DISCOVER   yt-dlp --flat-playlist -j  /  Data API videos.list
         |            -> candidate IDs (cheap, no media)
         v
    [2] FILTER     full metadata (--skip-download): duration, license,
         |            language, views, date, category, max height
         |            -> selected IDs + metadata.jsonl
         v
    [3] DOWNLOAD   job queue -> workers (yt-dlp, 1 proxy session each)
         |            video | audio-only | subs | thumbnail | info.json
         v
    [4] PROCESS    shot detection -> clips -> dedup -> quality filters
         |            -> captions / ASR / language ID
         v
    [5] STORE      WebDataset tar shards + Parquet metadata + sha256 manifest
         |            -> object storage (S3 / GCS / Azure Blob)
         v
    [6] GOVERN     lineage per sample, takedown list, re-release builds

    Stage 7, cost and throughput planning, applies to all of them. Each stage writes its output to durable storage, so a failure in stage 4 never forces a re-download from stage 3.

    Stage 1: Scope and discovery

    Start from what the model needs: target hours, modalities (video+audio, audio only, video+captions), languages, domains and minimum resolution. Then build seed lists:

    • Channels and playlists are the highest-yield seeds. Topically coherent channels produce consistent data.
    • Keyword lists produce search result pages. Each query yields a limited number of results, so you need many queries.
    • Existing ID lists from open datasets. Panda-70M, HD-VILA-100M and InternVid all ship YouTube IDs, covered in our guide to downloading Panda-70M, HD-VILA-100M and InternVid.

    Enumerate without resolving each video. In yt-dlp, --flat-playlist skips extraction of a playlist's entries, and -j/--dump-json prints one JSON object per entry without downloading:

    yt-dlp --flat-playlist -j "https://www.youtube.com/@CHANNEL/videos" > seeds/channel.jsonl
    yt-dlp --flat-playlist -j "ytsearch200:woodworking tutorial" > seeds/q_woodworking.jsonl

    Flat entries are thin: the README warns that "some entry metadata may be missing". Typically you get an ID, a title and maybe a duration. Treat this as the candidate list, not as filter input.

    YouTube Data API v3 as an alternative

    Google's quota documentation currently says projects get a default allocation of 100 search.list calls, 100 videos.insert calls, and 10,000 units per day for all other endpoints combined. Quotas reset at midnight Pacific Time. videos.list costs 1 unit and accepts a comma-separated list of IDs. The video resource exposes status.license (creativeCommon vs. youtube) and snippet.defaultAudioLanguage. That makes the API good for authoritative enrichment of IDs you already have, and a poor fit for discovery at scale given the search cap. Older articles quote "10,000 units, search costs 100", so check the current page before you plan around either model.

    Stage 2: Filter on metadata before downloading

    Video bytes are the main cost. Metadata costs a few requests per video. Fetch full metadata for every candidate, then drop what you won't train on:

    Filter yt-dlp field Why
    Duration duration Drop shorts and multi-hour streams that blow up processing.
    Live status live_status, is_live Skip ongoing and upcoming streams.
    License license Creative Commons-only subsets. YouTube CC videos report "Creative Commons Attribution license (reuse allowed)".
    Language subtitles, automatic_captions, per-format language Cheap pre-filter. Confirm later with audio language ID.
    Popularity / date view_count, upload_date Proxy for production quality; time-sliced splits.
    Category categories, tags Domain balance (e.g. cap gaming or music).
    Resolution formats[].height Skip videos whose best format is below your minimum.
    Availability availability, age_limit Skip anything that needs sign-in.

    The CLI route uses --skip-download with --write-info-json and --match-filters:

    yt-dlp -a candidates.txt --skip-download --write-info-json \
      --match-filters "duration>=30 & duration<=1800 & !is_live & license~='Creative Commons'" \
      -o "meta/%(id)s.%(ext)s"

    For a pipeline, the Python API is easier to control. The script below reads IDs, fetches metadata with extract_info(download=False), keeps the fields you filter on, and appends JSONL. It's resumable: IDs that already have a record are skipped, and failures go to a separate file so they get retried on the next run.

    import json
    import os
    import sys
    from datetime import datetime, timezone
    
    import yt_dlp
    from yt_dlp.utils import DownloadError
    
    KEEP = ["id", "title", "channel", "channel_id", "duration", "upload_date",
            "license", "categories", "tags", "view_count", "availability",
            "live_status", "age_limit"]
    
    
    def done_ids(path):
        """IDs that already have a successful record (errors get retried)."""
        done = set()
        if os.path.exists(path):
            with open(path, encoding="utf-8") as f:
                for line in f:
                    try:
                        done.add(json.loads(line)["id"])
                    except (ValueError, KeyError):
                        continue  # tolerate a truncated last line
        return done
    
    
    def to_record(info):
        rec = {k: info.get(k) for k in KEEP}
        formats = info.get("formats") or []
        rec["max_height"] = max((f.get("height") or 0 for f in formats), default=0)
        rec["audio_langs"] = sorted({f["language"] for f in formats
                                     if f.get("acodec") not in (None, "none")
                                     and f.get("language")})
        rec["subtitle_langs"] = sorted(info.get("subtitles") or {})
        rec["has_auto_captions"] = bool(info.get("automatic_captions"))
        return rec
    
    
    def now():
        return datetime.now(timezone.utc).isoformat(timespec="seconds")
    
    
    def main(ids_path, out_path, proxy=None):
        with open(ids_path, encoding="utf-8") as f:
            ids = list(dict.fromkeys(line.strip() for line in f if line.strip()))
        done = done_ids(out_path)
        todo = [i for i in ids if i not in done]
    
        opts = {"quiet": True, "no_warnings": True, "skip_download": True,
                "socket_timeout": 30, "extractor_retries": 3}
        if proxy:
            opts["proxy"] = proxy  # e.g. http://USER:[email protected]:8000
    
        with yt_dlp.YoutubeDL(opts) as ydl, \
                open(out_path, "a", encoding="utf-8") as out, \
                open(out_path + ".errors", "a", encoding="utf-8") as err:
            for vid in todo:
                url = f"https://www.youtube.com/watch?v={vid}"
                try:
                    info = ydl.sanitize_info(ydl.extract_info(url, download=False))
                except DownloadError as e:
                    err.write(json.dumps({"id": vid, "error": str(e),
                                          "fetched_at": now()}) + "\n")
                    continue
                rec = to_record(info)
                rec["fetched_at"] = now()
                out.write(json.dumps(rec, ensure_ascii=False) + "\n")
                out.flush()
    
    
    if __name__ == "__main__":
        if len(sys.argv) not in (3, 4):
            sys.exit("usage: fetch_meta.py ids.txt metadata.jsonl [proxy_url]")
        main(*sys.argv[1:])

    Run it as python fetch_meta.py ids.txt metadata.jsonl. Then filter metadata.jsonl with pandas or DuckDB to produce selected.txt. sanitize_info is there because the yt-dlp README says extract_info's return value isn't guaranteed to be JSON-serializable. Current yt-dlp lists Python 3.11+ as supported, and it needs a JavaScript runtime plus yt-dlp-ejs for full YouTube support. If metadata requests start failing with bot checks, see fixing "Sign in to confirm you're not a bot".

    Stage 3: Download

    One yt-dlp invocation per job, configured for the modality you need:

    # video + subtitles + thumbnail + metadata, capped at 720p
    yt-dlp -a selected.txt \
      -S "res:720,fps" --merge-output-format mp4 \
      --write-subs --write-auto-subs --sub-langs "en.*,ja" --sub-format "vtt/best" \
      --write-thumbnail --write-info-json \
      --download-archive archive.txt \
      -R 10 --fragment-retries 10 --retry-sleep exp=1:60 \
      --sleep-requests 1 --sleep-subtitles 2 \
      --proxy "http://USER:[email protected]:8000" \
      -o "raw/%(id)s/%(id)s.%(ext)s"
    
    # speech corpus: best audio-only stream, no video bytes at all
    yt-dlp -a selected.txt -f "ba" --download-archive archive_audio.txt \
      -o "raw_audio/%(id)s.%(ext)s"
    • Format selection. -f chooses formats. -S sorts them, and res:720 prefers the largest resolution not above 720p. Pin a resolution ceiling: the bytes difference between 720p and 4K dominates your bill.
    • Audio-only. -f ba keeps the native stream (usually Opus or AAC). Add -x --audio-format flac only if your ASR stage needs it. Decoding to 16 kHz mono at training time is usually cheaper than storing WAV.
    • Subtitles. --write-subs fetches uploader captions and --write-auto-subs fetches YouTube's ASR. Keep them separate in metadata: they differ in quality, and auto-captions are a weak label.
    • Idempotency. --download-archive records completed IDs, so re-runs skip them. At scale, put IDs in a queue (Redis, SQS, a Postgres table with SELECT ... FOR UPDATE SKIP LOCKED), lease jobs with a timeout, and mark a job done only after the upload to object storage and its checksum are confirmed.
    • Proxies. Give each worker its own proxy session and keep one video on one IP for its whole lifetime (metadata, manifest, fragments). Details, including per-worker configuration, are in the yt-dlp proxy guide.

    Stage 4: Processing

    Shot detection and clip splitting

    Most video-text training uses clips of a few seconds to a minute, not whole videos. PySceneDetect provides ContentDetector, a two-pass AdaptiveDetector that handles fast camera movement better, and ThresholdDetector for fades. Splitting needs ffmpeg (or mkvmerge):

    from scenedetect import detect, AdaptiveDetector, split_video_ffmpeg
    
    scenes = detect("raw/abc123XYZ_-/abc123XYZ_-.mp4", AdaptiveDetector())
    keep = [(s, e) for s, e in scenes if 2.0 <= (e - s).get_seconds() <= 60.0]
    split_video_ffmpeg("raw/abc123XYZ_-/abc123XYZ_-.mp4", keep)

    Raw shot boundaries over-split. Panda-70M runs shot boundary detection with PySceneDetect first. It then uses ImageBind frame embeddings to stitch adjacent clips that are semantically similar. Its splitting code also cuts the first 5 seconds of any clip longer than 7 seconds, recursively, to catch fades and unedited footage. Store clip boundaries as timestamps in metadata. Whether you materialize cut files or decode ranges at load time is a storage-vs-compute trade-off.

    Deduplication

    • Exact: sha256 of the downloaded file. This only catches byte-identical copies.
    • Perceptual: re-uploads are re-encoded, so hash sampled frames (e.g. 1 fps) with a perceptual hash and compare sequences by Hamming distance.
    • Semantic near-dup: embed clips with an image/video encoder, index them in an approximate-nearest-neighbor index, and drop pairs above a cosine-similarity threshold you tune on a labeled sample.
    • Against evaluation sets: drop any video ID that appears in benchmarks you report on, to avoid contamination.

    Quality filters

    The Stable Video Diffusion paper is one of the clearer public write-ups. It uses cascaded cut detection, dense optical flow computed at 2 FPS to remove near-static clips below a motion threshold, text detection to remove clips with excessive text, and CLIP-based aesthetic and text-image similarity scores. The same paper notes that InternVid-10M is "specifically filtered for high aesthetics". Practical guidance:

    • Compute scores and store them as metadata columns rather than deleting clips. Thresholds change between training runs, and re-scoring is expensive.
    • Calibrate each threshold by viewing a few hundred clips on both sides of it.
    • Text-heavy filters (slides, lyric videos, screen recordings) matter for generation models. For document-understanding models you may want exactly those clips.

    Captioning and recaptioning

    Titles and descriptions are noisy captions, and ASR transcripts describe speech, not visuals. Panda-70M's approach is to caption each clip with multiple cross-modality teacher models (using frames, the video description and subtitles as inputs). A retrieval model, fine-tuned on a small manually-labeled subset, then picks the best caption per clip. A simpler version: run one strong vision-language model per clip, prompted with the video title and the ASR text for that time range. Store the model name and prompt version with every caption.

    ASR and language ID

    Whisper does multilingual transcription, translation and spoken language identification. The turbo model is an optimized large-v3 with faster transcription and minimal accuracy loss. Per the README, it isn't trained for translation. For language ID, model.detect_language(mel) returns per-language probabilities. Compare the result with the metadata language and caption tracks: mismatches often flag dubbed content, music, or mislabeled uploads. Whisper's accuracy varies widely by language (the README publishes per-language WER/CER), so set per-language confidence thresholds.

    Stage 5: Storage layout and formats

    WebDataset stores samples as plain tar files. Files belonging to one sample share a basename, and shards are numbered like name-000000.tar. That gives sequential reads from object storage and trivial parallelism.

    s3://bucket/ytds/v2026-11/
      shards/clips-000000.tar        # abc123XYZ_-_000012.mp4 / .json / .txt / .opus
      shards/clips-000001.tar
      metadata/clips.parquet         # one row per clip: scores, captions, lineage
      metadata/videos.parquet        # one row per source video
      manifest.jsonl                 # one line per object: path, bytes, sha256
      takedowns.jsonl                # removed video_ids, with date and reason
    • Keep raw downloads in a separate prefix from training shards, so you can re-cut or re-filter without re-downloading.
    • Parquet for analytical metadata (filtering, stats, joins). JSONL for append-only logs and human-readable manifests.
    • Shard size: pick a size that streams efficiently from your object store. Don't write millions of small per-clip objects.
    • Version releases (v2026-11) and never mutate a published shard. Takedowns produce a new release.

    Stage 6: Data governance and lineage

    Every sample should answer: where did this come from, when, under what license, and has it been withdrawn? A clip-level record (illustrative values):

    {
      "sample_id": "abc123XYZ_-_000012",
      "video_id": "abc123XYZ_-",
      "source_url": "https://www.youtube.com/watch?v=abc123XYZ_-",
      "channel_id": "UCxxxxxxxxxxxxxxxxxxxxxx",
      "license": "Creative Commons Attribution license (reuse allowed)",
      "upload_date": "20240611",
      "collected_at": "2026-10-28T14:03:11Z",
      "collector_version": "yt-dlp 2026.10.xx / pipeline 1.4.0",
      "clip": {"start": 83.417, "end": 91.758, "height": 720, "fps": 30},
      "audio": {"lang_meta": "en", "lang_whisper": "en", "lang_prob": 0.97},
      "captions": [
        {"source": "vlm", "model": "MODEL_NAME", "prompt_version": "v3", "text": "..."},
        {"source": "asr", "model": "whisper-turbo", "text": "..."}
      ],
      "scores": {"motion": 3.1, "ocr_text_area": 0.02, "aesthetic": 5.4},
      "dedup_cluster": 1882301,
      "shard": "clips-000417.tar",
      "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
    }

    Takedown handling is a join: keep takedowns.jsonl keyed by video_id or channel_id, rebuild the affected shards without those samples, publish a new version, and delete the raw files. Periodically re-check source availability. A video that became private or was deleted is a strong signal to drop it from future releases.

    Stage 7: Cost and throughput planning

    Size everything from hours and bitrate:

    bytes      = hours x 3600 x avg_bitrate_bps / 8
    throughput = bytes x 8 / collection_window_seconds
    
    example (assumed 2 Mbps average at a 720p cap):
      1,000,000 h x 3600 x 2e6 / 8   = 9.0e14 B  = 900 TB
      9.0e14 x 8 / (30 x 86400 s)    = ~2.78 Gbps sustained for 30 days

    Measure your own average bitrate from filesize/filesize_approx and duration in a metadata sample, because it varies widely by resolution, codec and content. Then budget for headroom: retries, metadata requests, re-downloads after failures, and the fraction of IDs that turn out unavailable. GPU stages (embedding, captioning, ASR) usually become the bottleneck after download. Benchmark clips per GPU-hour on your own hardware before you commit to a schedule. Detailed tables are in bandwidth planning for large-scale video downloads.

    Compliance

    YouTube's Terms of Service restrict automated access and downloading. Copyright and licensing still apply to the content: some teams restrict collection to Creative Commons-licensed videos and track the license per sample. Faces and voices are personal data under regimes like GDPR. Honor takedown and opt-out requests, and design the pipeline so you can. Get legal review for your jurisdiction and use case before you collect or redistribute.

    If you'd rather not run this yourself

    Corpora offers dedicated 1–40 Gbps unmetered proxy capacity for teams running their own yt-dlp stack. It also offers a fully managed option that collects, deduplicates and delivers video, audio, subtitles and metadata (JSONL/Parquet with sha256 checksums and manifests) to your S3, GCS or Azure bucket, with a free sample batch. Get in touch to scope it.

    FAQ

    How do I get only metadata from YouTube with yt-dlp?

    Use -j/--dump-json to print JSON without downloading, or --skip-download --write-info-json to write .info.json files. Add --flat-playlist when you only need the entry list of a channel or playlist.

    How many API calls does the YouTube Data API allow per day?

    Google's quota page currently states a default of 100 search.list calls, 100 videos.insert calls and 10,000 units per day for other endpoints, resetting at midnight Pacific Time. You can request more through Google's quota audit process.

    How do I filter for Creative Commons videos?

    In yt-dlp, filter on the license field (e.g. --match-filters "license~='Creative Commons'"). In the Data API, read status.license, which is creativeCommon for CC videos.

    Should I store whole videos or pre-cut clips?

    Keep the raw videos plus clip timestamps as the source of truth. Materialize cut clips into WebDataset shards for each training release. That way, changing the splitting or filtering logic doesn't require a re-download.

    Which ASR model should I use for a YouTube speech dataset?

    Whisper is the common default. turbo is the faster option for transcription, while the multilingual models are needed for translation. Validate WER on a hand-transcribed sample per language before trusting the transcripts as labels.

  • Download YouTube Video Datasets: Panda-70M, HD-VILA, InternVid

    Panda-70M, HD-VILA-100M, InternVid and most other large video-language datasets don't ship any video. You get YouTube IDs, clip timestamps and captions, and you have to collect the videos yourself. This guide covers what each dataset actually releases (checked against the paper and the official repo), which tooling works for it, and how to plan storage, failures and scaling before you start a multi-terabyte download.

    TL;DR

    • Panda-70M, HD-VILA-100M, InternVid, HowTo100M, AudioSet and VoxCeleb2 all release metadata only (IDs, timestamps, text or labels). Kinetics-700-2020 is the exception: CVDF hosts the video files.
    • For Panda-70M, use the video2dataset fork inside the Panda-70M repo. The authors say upstream video2dataset can't read their CSV format.
    • Many clips per source video: download the video once and cut it locally with ffmpeg. One short segment per video: use yt-dlp --download-sections.
    • Expect missing videos. Kinetics-700-2020's own download log shows about 2% of videos already gone a few months after the 2020 release.
    • Storage ≈ hours × 3600 × bitrate ÷ 8. Panda-70M's own figure (~36 TB for 167k hours at 360p) works out to roughly 0.48 Mbps.
    • Annotation licenses don't give you any rights to the underlying videos.

    Why these datasets ship as IDs and timestamps

    The videos belong to their uploaders. Dataset authors can license their annotations (captions, labels, clip boundaries), but they generally can't redistribute the footage. What you download is a table of pointers, so every team that wants the pixels has to collect them again. This has three practical effects: your copy of the dataset is smaller than the paper's, it shrinks over time, and it depends on how well your downloader keeps up with YouTube.

    Dataset overview

    All numbers below come from each dataset's paper, project page or official repo. Durations appear only where the source states them.

    Dataset (year) What's released Source videos Clips / segments Stated duration Annotation license Official tooling
    Panda-70M (2024) CSV with YouTube URL, clip timestamps, caption, matching score; desirability and shot-boundary annotations added Oct 2024 3,779,763 (full train) 70,723,513 167k hrs (clips) Snap non-commercial research license video2dataset fork in snap-research/Panda-70M
    HD-VILA-100M (2022) Video URLs plus clip spans, separate metadata archive; text is ASR transcripts 3.3M 100M (avg 13.4 s) 371.5k hrs, 720p Research Use of Data Agreement (R-UDA) v1.0 src/cut_videos.py in microsoft/XPretrain (cutting only)
    InternVid (2023) YouTube IDs, clip timestamps, generated captions; subsets 10M-FLT, 10M-DIV, 18M-AES; full annotations as InternVid-Full 7M+ 234M (paper); 10M in -FLT ~760k hrs (full) CC BY-NC-SA 4.0 (Hugging Face card, gated) None for video download in the README
    HowTo100M (2019) YouTube IDs; captions are narrations from YouTube's automatic subtitles; precomputed S3D features 1.22M 136M "15 years of video" (project page) Not stated on the project page Feature-extraction code in antoine77340/howto100m
    Kinetics-700 / -2020 YouTube ID, ~10 s segment, action label. CVDF also hosts the video files One clip per video 647,907 (2020 report: 545,793 / 34,256 / 67,858) Not stated Not verified here; check DeepMind's Kinetics page cvdfoundation/kinetics-dataset (k700_2020_downloader.sh)
    AudioSet (2017) CSV: YTID, start, end, labels; 128-d audio features (TFRecord) One segment per video 2,085,544 ten-second segments, 527 classes Not stated (≈5,793 hrs at 10 s each) CC BY 4.0 (ontology CC BY-SA 4.0) None for raw audio
    VoxCeleb2 (2018) YouTube URLs and utterance timestamps. The site now says URLs, audio and video are "no longer available from this website" 150,480 1,128,246 utterances, 6,112 speakers Not stated Metadata CC BY-SA 4.0 None currently

    YT-Temporal-180M (6 million videos, 180M extracted frames) and YT-Temporal-1B (MERLOT Reserve, 20 million videos) aren't in the table. The MERLOT repo still lists "information about the videos used in this work" as planned, and I found no public ID list for either. Ask the authors directly.

    HD-VILA's numbers are consistent with each other: 100M clips × 13.4 s ≈ 372k hours. That means the 371.5k hours figure counts clip duration, not full source-video duration.

    Step 1: get the metadata

    • Panda-70M: CSVs are linked from the dataset_dataloading README. Training sets are zipped (full: 2.73 GB; 10M: 504 MB; 2M: 118 MB). The 10M subset keeps at most 3 clips per source video, and every clip has a matching score above 0.43.
    • HD-VILA-100M: the README links a URL archive and a metadata archive on Azure Blob, last updated August 2026. Each JSONL record has video_id, url and a clip list of {clip_id, span: [start, end]}.
    • InternVid: OpenGVLab/InternVid on Hugging Face is gated. You fill in name, organization and email and accept the terms before the JSONL files become available.
    • AudioSet: three CSVs (eval 20,383, balanced train 22,176, unbalanced train 2,042,985 segments).

    Step 2: choose your downloader

    Panda-70M: the official video2dataset fork

    Panda-70M's README says to use the bundled fork, because "the video2dataset from the official repository cannot work with our csv format":

    git clone https://github.com/snap-research/Panda-70M.git
    cd Panda-70M/dataset_dataloading/video2dataset
    pip install -e .
    cd ..
    
    video2dataset --url_list="panda70m_training_2m.csv" \
                  --url_col="url" \
                  --caption_col="caption" \
                  --clip_col="timestamp" \
                  --output_folder="panda70m_2m" \
                  --save_additional_columns="[matching_score,desirable_filtering,shot_boundary_detection]" \
                  --config="video2dataset/video2dataset/configs/panda70m.yaml"

    The bundled panda70m.yaml sets download_size: 360 and download_audio: True, downloads English subtitles, and writes 100 samples per shard using 32 processes × 32 threads. The README's known-issues table maps HTTP 403 to "your IP got blocked" and HTTP 429 to "reduce processes_count and thread_count". It also notes that several YouTube errors are fixed by upgrading yt-dlp, so run pip install -U "yt-dlp[default]" inside that environment after installing. Current yt-dlp needs Python 3.11+, plus yt-dlp-ejs and a JavaScript runtime such as Deno for full YouTube support (see the yt-dlp README).

    Upstream video2dataset

    iejMac/video2dataset is still at that address (MIT license, not archived). Its last push was in July 2024, and the latest PyPI release is 1.3.0 from February 2024, so treat it as stable but largely unmaintained. It reads CSV or Parquet (--input_format), writes files, webdataset, parquet or tfrecord (--output_format), and distributes work over multiprocessing, PySpark or Slurm. The README says PySpark mode was used to download 40M videos. It's a reasonable base for InternVid or HD-VILA, but you'll have to convert their timestamp formats to whatever clip_col format your version expects. Test on 1,000 rows before launching a full run.

    yt-dlp directly

    For custom pipelines, call yt-dlp directly. How you handle clipping depends on how many clips come from each source video:

    • Many clips per video (Panda-70M averages ~18.7 clips per source video in the full set; HD-VILA has ~30): download the whole video once, then cut it locally.
    • One short segment per video (AudioSet, Kinetics): download just the section with --download-sections. The * prefix marks a time range, and the option needs ffmpeg.
    # Full video, capped at 360p, smallest file among the remaining candidates
    yt-dlp -S "res:360,+size" --merge-output-format mp4 \
      -o "videos/%(id)s.%(ext)s" \
      "https://www.youtube.com/watch?v=QMi8x8o55Ns"
    
    # One 10-second AudioSet segment, audio only
    yt-dlp -f bestaudio -x --audio-format flac \
      --download-sections "*420-430" \
      -o "audio/%(id)s_%(section_start)s.%(ext)s" \
      "https://www.youtube.com/watch?v=-0RWZT-miFs"

    Section downloads cut at keyframes by default. Add --force-keyframes-at-cuts for accurate boundaries. The README warns that this is slow because it re-encodes.

    Cutting clips with ffmpeg

    HD-VILA's cut_videos.py uses ffmpeg -ss … -t … -c copy. Stream copy is fast, but each clip starts at the nearest keyframe, so a 5-second clip can pick up extra frames. If your training needs exact caption alignment, re-encode:

    import json
    import subprocess
    from pathlib import Path
    
    def to_sec(ts):  # "00:04:52.140" to 292.14
        h, m, s = ts.split(":")
        return int(h) * 3600 + int(m) * 60 + float(s)
    
    with open("meta_part0.jsonl") as f:  # one HD-VILA metadata part
        for line in f:
            meta = json.loads(line)
            src = Path("videos") / f"{meta['video_id']}.mp4"
            if not src.exists():
                continue
            out_dir = Path("clips") / meta["video_id"]
            out_dir.mkdir(parents=True, exist_ok=True)
            for c in meta["clip"]:
                start, end = (to_sec(t) for t in c["span"])
                subprocess.run([
                    "ffmpeg", "-y", "-loglevel", "error",
                    "-ss", f"{start:.3f}", "-i", str(src), "-t", f"{end - start:.3f}",
                    "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
                    "-c:a", "aac", "-b:a", "96k",
                    str(out_dir / c["clip_id"]),
                ], check=True)

    Choosing format and resolution

    Resolution has more effect on storage and bandwidth than any other setting. In yt-dlp's format sorting, res:360 prefers the largest format that is no larger than 360p. Adding +size breaks ties toward smaller files. Panda-70M's tooling defaults to 360p for this reason. If you're training generation models that need HD, set a higher download_size and budget for it. Use audio-only formats for AudioSet and VoxCeleb-style work. Also consider whether you need audio at all: Panda's README notes that turning off download_audio speeds up downloads.

    Link rot: plan for missing videos

    Videos get deleted, set to private, region-locked or taken down by their owners. CVDF's Kinetics-700-2020 readme is one of the few published measurements. It was downloaded in September 2020 with youtube-dl:

    • Train: 536,699 of 545,793 videos (98.3%)
    • Validation: 33,966 of 34,256 (99.2%)
    • Test: 64,535 of 67,858 (95.1%)

    About 7,000 videos that were unavailable by September 2020 were recovered from a December 2019 crawl. Expect losses to grow the longer a dataset has been public. Panda-70M's README lists "set to private or removed" as an expected failure and tells you to skip those samples. VoxCeleb2's official site no longer provides the URL list at all. In practice, always report how many clips you actually obtained, and don't assume your numbers are directly comparable to the paper's.

    Estimating storage

    Formula: bytes = hours × 3600 × bitrate (bits/s) ÷ 8.

    Panda-70M gives a calibration point: ~36 TB for 167k hours at 360p with audio. That's 36×10¹² B ÷ (167,000 × 3600 s) ≈ 59,900 B/s ≈ 0.48 Mbps. The table below assumes 0.5 Mbps for 360p and 2.5 Mbps for 720p H.264. These are round assumptions, not measurements.

    Dataset (stated hours) Seconds At 0.5 Mbps At 2.5 Mbps
    Panda-70M full (167k, clips) 6.01×10⁸ ≈ 37.6 TB ≈ 188 TB
    HD-VILA-100M (371.5k, clips) 1.34×10⁹ ≈ 83.6 TB ≈ 418 TB
    InternVid full (~760k, source videos) 2.74×10⁹ ≈ 171 TB ≈ 855 TB

    These estimates cover the clips you store. Transfer volume is higher if you download full source videos and keep only the clipped parts, and the sources don't state total source duration for Panda-70M or HD-VILA. For transfer time: 37.6 TB × 8 ÷ 1 Gbps ≈ 300,800 s ≈ 3.5 days at full line rate, which you won't sustain in practice. Bandwidth for large-scale video downloads goes through the full calculation.

    Output formats

    • Loose files (video2dataset's default files): Panda-70M's layout is one folder per shard, with .mp4, .txt (caption) and .json (metadata, including status) per sample. This is easy to inspect but slow on object storage once you have millions of files.
    • WebDataset tar shards (--output_format webdataset): this is what the video2dataset README recommends "for most use cases". It streams well from S3/GCS into PyTorch, TF or JAX.
    • Parquet metadata: video2dataset writes a Parquet file per shard alongside the tars. Merge these into a single manifest (clip ID, video ID, start, end, caption, status, error, bytes, resolution, sha256, download date) so you can filter and audit without opening any shards.

    Panda's README also warns that shards are unshuffled, so all clips from one source video end up in the same shard. Shuffle before training.

    Resumability and failure tracking

    video2dataset's incremental_mode (default incremental) skips finished shards on restart. This only works if you keep the same output folder, the same number_sample_per_shard and the same input list. Failed samples record "status": "failed_to_download" and an error_message in their JSON.

    With plain yt-dlp, --download-archive FILE skips IDs that have already been downloaded. You still need to separate permanent failures from transient ones. A minimal status log:

    import json
    import subprocess
    from pathlib import Path
    
    IDS = Path("video_ids.txt")
    LOG = Path("status.jsonl")
    OUT = Path("videos")
    PERMANENT = ("Video unavailable", "Private video", "removed by the uploader")
    
    finished = set()
    if LOG.exists():
        with LOG.open() as f:
            for line in f:
                rec = json.loads(line)
                if rec["status"] in ("ok", "gone"):
                    finished.add(rec["id"])
    
    OUT.mkdir(exist_ok=True)
    with LOG.open("a") as log:
        for vid in IDS.read_text().split():
            if vid in finished:
                continue
            p = subprocess.run(
                ["yt-dlp", "--no-progress", "-S", "res:360,+size",
                 "--merge-output-format", "mp4",
                 "-o", str(OUT / "%(id)s.%(ext)s"),
                 f"https://www.youtube.com/watch?v={vid}"],
                capture_output=True, text=True,
            )
            if p.returncode == 0:
                status = "ok"
            elif any(s in p.stderr for s in PERMANENT):
                status = "gone"
            else:
                status = "retry"  # rate limits, bot checks, network errors
            log.write(json.dumps({"id": vid, "status": status,
                                  "error": p.stderr.strip()[-500:]}) + "\n")
            log.flush()

    On the next run, IDs marked retry are attempted again, while ok and gone are skipped. YouTube's error strings change over time, so check a sample of retry errors periodically and update the matcher.

    Scaling: rate limits and bot checks

    At 10k+ videos, throughput is limited by YouTube's limits rather than your code. Typical failures are HTTP 429, 403 and "Sign in to confirm you're not a bot". Things that help:

    For the end-to-end pipeline after download (dedup, filtering, captioning), see building a YouTube video dataset for multimodal training.

    Licensing and compliance

    The licenses above (Snap's non-commercial license, R-UDA, CC BY-NC-SA 4.0, CC BY 4.0) cover the annotations. They grant no rights to the underlying videos, which belong to their uploaders. YouTube's Terms of Service restrict automated access and downloading. Copyright and licensing still apply, and some teams filter for Creative Commons-licensed videos. Faces and voices raise privacy issues (e.g., GDPR), and takedown and opt-out requests should be honored. Panda-70M's authors, for example, offer removal on request. Get legal review for your jurisdiction and use case before collecting or redistributing.

    If you'd rather not run this yourself

    Corpora offers two options. If you run your own stack, dedicated 1–40 Gbps unmetered proxy capacity (HTTP(S)/SOCKS5, compatible with yt-dlp) is $2,000 per Gbps per month. Alternatively, a fully managed collection service delivers video, audio, subtitles and metadata (JSONL/Parquet with sha256 checksums and manifests) to your S3, GCS or Azure bucket, priced per delivered PB, with a free sample batch. Contact us to scope it.

    FAQ

    Can I download Panda-70M videos directly instead of from YouTube?

    Not officially. The release consists of CSVs (URLs, timestamps, captions, scores) and code. The videos have to be fetched from YouTube with the bundled video2dataset fork or your own tooling.

    How big is Panda-70M once downloaded?

    The README lists ~36 TB for the full training set (70.7M clips, 167k hours), ~8.0 TB for the 10M subset and ~1.6 TB for the 2M subset, at the default 360p with audio.

    Can I use HD-VILA-100M or InternVid commercially?

    Their annotation licenses don't allow it: R-UDA v1.0 is limited to non-commercial research, and InternVid is CC BY-NC-SA 4.0. Separately, neither license covers rights to the videos themselves.

    Why do I get fewer clips than the paper reports?

    Link rot. Videos get removed or set to private over time. Kinetics-700-2020's own download already missed about 2% within months of release. Log every failure so you can report what you actually obtained.

    Should I use video2dataset or yt-dlp?

    Use the official fork for Panda-70M. Elsewhere, video2dataset gives you sharding, WebDataset/Parquet output and resumption. Plain yt-dlp gives you more control, but you write the bookkeeping yourself.