Library Search
Search a library of MP4E videos in the browser, straight from the search card each exported file carries — no search server, no player, no key.
Overview
Every analysed MP4E video carries a public search card. @mp4e/search turns those cards into search for your whole library, in the visitor's browser: it reads the card out of each file, ranks the videos against a query, and tells you where in each video the match is, so a result can open the video at that moment.
- No server. The files are the index. Point it at the video URLs you already serve.
- Small reads. Only the card is fetched — a few kilobytes per video — never the video itself.
- Public data only. It reads the card the creator chose to publish; the protected interactive metadata is never touched.
- Headless. It returns ranked results; the search box and the results list are yours to design.
- No dependencies. It does not need the MP4E player on the page.
Install
npm install @mp4e/searchQuick start
1import { readCards, createLibrary } from '@mp4e/search';23// Your library — any shape you like; the search hands your entries back.4const videos = [5 { id: 'podcast', title: 'MP4E Podcast', url: 'https://cdn.example.com/podcast.mp4' },6 { id: 'cooking', title: 'Cooking for kids', url: 'https://cdn.example.com/cooking.mp4' },7];89// 1. Read the search card of every file (a few KB each, 4 files at a time).10const outcomes = await readCards(videos, (video) => video.url);1112// 2. Build a library from the files that carry a card.13const library = createLibrary(14 [...outcomes].flatMap(([entry, outcome]) =>15 outcome.status === 'card' ? [{ entry, card: outcome.card }] : []),16);1718// 3. Search.19for (const result of library.search('banana smoothie')) {20 console.log(result.entry.title, result.fields, result.moments);21}Reading cards
readCard reads one file. For a URL it walks the MP4 structure with small HTTP Range requests and downloads only the card atom — wherever the file keeps it, at the start or at the end. A file the visitor picks, or bytes you already hold, are read the same way without any request.
import { readCard } from '@mp4e/search';
// A URL (read with HTTP Range requests)…
const card = await readCard('https://cdn.example.com/podcast.mp4');
// …or a file the visitor picked, or bytes you already hold.
const fromFile = await readCard(fileInput.files[0]);
const fromBytes = await readCard(arrayBuffer);
if (card === null) console.log('This file carries no search card.');readCards reads a whole library. Each file settles on its own and is reported to onCard as it lands, so results can fill in while the slower files are still being read:
const cards = new Map(); // video id → card
await readCards(videos, (video) => video.url, {
concurrency: 4,
onCard: (video, outcome) => {
if (outcome.status === 'card') cards.set(video.id, { entry: video, card: outcome.card });
// Re-run the current query as each card lands.
render(createLibrary([...cards.values()]).search(currentQuery));
},
});| Prop | Type | Default | Description |
|---|---|---|---|
concurrency | number | 4 | How many files are read at the same time. |
onCard | (entry, outcome) => void | - | Called as each file settles, in completion order — render results progressively from here. |
Each entry ends in one of three outcomes:
| Prop | Type | Default | Description |
|---|---|---|---|
{ status: 'card', card } | CardOutcome | - | The file carries a search card. |
{ status: 'none' } | CardOutcome | - | The file was read and carries no search card (an older export, or the creator switched the card off). |
{ status: 'error', error } | CardOutcome | - | The file could not be read: an MP4ReadError or a SearchCardError. |
What your storage must allow
Cards are read with Range requests, the same requests a video element makes to seek. Most CDNs and object stores (S3, CloudFront, Cloudflare R2, Google Cloud Storage) answer them by default. When the page and the videos are on different origins, the storage must also allow your page in its CORS rules:
# The headers your video storage must send for a cross-origin page
Access-Control-Allow-Origin: https://your-site.example
Access-Control-Expose-Headers: Content-Range
Accept-Ranges: bytes # and answer Range requests with 206 Partial Contentrange-unsupported for that file. The package never downloads a whole video to find its card.When a card cannot be read
A file without a card is not an error — readCard returns null. Everything else is a typed error:
import { readCard, MP4ReadError, SearchCardError } from '@mp4e/search';
try {
const card = await readCard(url);
} catch (err) {
if (err instanceof MP4ReadError) {
// err.kind: 'network' | 'http' | 'short-read' | 'range-unsupported' | 'too-large'
} else if (err instanceof SearchCardError) {
// err.kind: 'unsupported-version' | 'malformed'
}
}| Prop | Type | Default | Description |
|---|---|---|---|
MP4ReadError: network | kind | - | The request failed — offline, a CORS refusal, or an aborted connection. |
MP4ReadError: http | kind | - | The server answered with an error status (err.status holds it). |
MP4ReadError: range-unsupported | kind | - | The server ignored the Range request. Nothing large is downloaded: the read stops at once. |
MP4ReadError: too-large | kind | - | The card is larger than 1 MB, which no real card is. |
MP4ReadError: short-read | kind | - | The server sent fewer bytes than the file promised. |
SearchCardError: unsupported-version | kind | - | The card was written in a layout this version of the package does not read. |
SearchCardError: malformed | kind | - | The card is not valid JSON, or not a search card. |
Searching
createLibrary takes { entry, card } pairs — your own record for each video plus its card — and search returns your entries ranked, with what matched:
| Prop | Type | Default | Description |
|---|---|---|---|
entry | T | - | Your entry, exactly as you passed it to createLibrary. |
card | SearchCard | - | The video's search card. |
score | number | - | Weighted score; higher is a better match. Comparable only within one search. |
matchedWords | string[] | - | The query's words this video holds (in their normalised form). |
fields | CardField[] | - | The parts of the card that matched, strongest first — e.g. title, topics, chapters, transcript. Useful for a "Matched in …" line. |
moments | Moment[] | - | Up to three places in the video that hold the query words, each with a time in seconds. See Moments. |
How results are ranked
- The query is split into words; case, common words ("the", "how", "video"…) and word endings are ignored, so cook finds cooking and smoothie finds smoothies.
- A word of four letters or more also matches the start of longer words, so results appear while the visitor is still typing (smoo finds smoothie). These partial matches count half as much as whole words.
- Videos holding more of the query's words rank first; among them, the higher score wins. A multi-word query that appears as a phrase in the title or a search phrase earns a bonus.
- Where a word is found decides how much it counts — a title outweighs a passing mention:
| Prop | Type | Default | Description |
|---|---|---|---|
title, searchPhrases, tags | 3 | - | The video title, the phrases people would use to look for it, and the creator’s catalogue tags. |
oneLine, topics, questions, categories | 2 | - | The one-line summary, topics, the questions it answers, and the categories of the things in it. |
chapters, keyMoments, people, things, paragraph, catalogFields, transcript | 1 | - | Chapter and moment titles, people, things, the paragraph summary, public catalogue fields, and what is said. |
The transcript is searched only when the creator chose to include it in the card (an export setting, off by default), so a video can be found by what is said in it.
Moments
Each result lists up to three timed places that hold the query's words — the ones holding the most words first, and chapters, key moments and questions before lines of the transcript when they match as well. Render them as chips that open the video at time.
| Prop | Type | Default | Description |
|---|---|---|---|
kind | 'chapter' | 'keyMoment' | 'question' | 'said' | - | Where the match is: a chapter title, a key moment, a question the video answers, or a line of the transcript. |
title | string | - | The chapter, moment or question text. For said, a short quote of the transcript line around the match (at most 80 characters, cut on word boundaries). |
time | number | - | Seconds from the start of the video. |
Opening a result at its moment
Pass the moment's time to the player as initialTime. The player starts there as soon as the video's interactivity has loaded:
import { MP4EPlayer } from '@mp4e/react';
function Watch({ result, moment }) {
// moment: one of result.moments, or undefined to start at the top
return <MP4EPlayer src={result.entry.url} initialTime={moment?.time} />;
}import { MP4Engine } from '@mp4e/player';
const engine = new MP4Engine(
{ video, overlayContainer },
{ initialTime: moment.time },
);
await engine.init();Plugins learn that the viewer arrived at a moment (getVideoState().startTime) and skip start-of-video prompts — the Cuts plugin, for example, does not ask "How long have you got?" and keeps the viewer on the moment they came for.
Hover previews
A plain, muted <video> seeked to the moment's time makes a light preview: it plays the same file the player will open, needs no extra storage, and keeps the result's thumbnail on screen until its first frame. Keep the interactive player for the click — at thumbnail size its overlays would be unreadable, and it would load far more than a preview needs.
function startPreview(thumb, url, time) {
const video = document.createElement('video');
video.muted = true;
video.playsInline = true;
video.preload = 'metadata';
video.src = url;
video.addEventListener('loadedmetadata', () => {
video.currentTime = time;
video.play().catch(() => {}); // the thumbnail stays if the browser refuses
}, { once: true });
video.addEventListener('playing', () => video.classList.add('visible'), { once: true });
thumb.append(video);
return () => video.remove(); // call on pointer leave
}Large libraries
Reading cards live suits libraries of tens of videos. For hundreds, read each card once — at upload or build time — and store the cards next to your catalogue. A card is plain JSON, and createLibrary does not care where it came from:
// Build time (Node 18+): read each card once and store it next to your catalogue.
import fs from 'node:fs/promises';
import { readCard } from '@mp4e/search';
const index = [];
for (const video of catalogue) {
const card = await readCard(video.url);
if (card) index.push({ entry: { id: video.id, url: video.url, title: video.title }, card });
}
await fs.writeFile('search-index.json', JSON.stringify(index));
// Page: load the stored cards — no file reads at all.
import { createLibrary, parseCard } from '@mp4e/search';
const stored = await (await fetch('/search-index.json')).json();
const library = createLibrary(stored.map(({ entry, card }) => ({ entry, card: parseCard(card) })));Re-read a card whenever its file is replaced: the card changes when the creator re-exports.
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
readCard(source) | Promise<SearchCard | null> | - | The search card of one file. source is a URL, a File or Blob, an ArrayBuffer or a Uint8Array. null when the file carries no card. |
readCards(entries, getSource, options?) | Promise<Map<T, CardOutcome>> | - | Reads many files; every entry settles on its own, so one bad file never fails the batch. |
createLibrary(items) | Library<T> | - | A searchable library over { entry, card } pairs, from live reads or stored cards. |
library.search(query, { limit? }) | SearchResult<T>[] | - | Ranked matches, at most limit (default 20). Empty for a query with no searchable words. |
library.size | number | - | How many videos the library holds. |
parseCard(json) | SearchCard | - | Validates a card you stored yourself (throws SearchCardError). |
terms(text) / stem(word) | string[] / string | - | The normalisation the search uses — handy for highlighting matched words in your own UI. |
quote(line, words) | string | - | The short quote around a match that said moments carry. |
Types: SearchCard, ByteSource, CardOutcome, Library, SearchResult, Moment and CardField. The card's own fields are listed in The card's fields.