Generate Video Chapters from Transcripts
Zied · 8/15/2026 · 8 min read
Generate Video Chapters and Timestamps from Transcripts
Adding chapters to a YouTube video lets viewers jump to the section they care about, and it signals to search engines exactly what each portion of your video covers. You can generate them programmatically by fetching the transcript with timestamps and running it through an LLM. Here is the complete pipeline.
Why Chapters Matter for Retention and SEO
YouTube chapters add a segmented progress bar to your video. Viewers can see at a glance what topics you cover and skip directly to them. That single change tends to lift engagement, because a viewer who can find what they want quickly is more likely to stay.
From an SEO standpoint, chapters unlock Google Key Moments. When your video has chapter markers, Google can index individual segments and surface them directly in search results with a timestamp link. That means your video can appear not once but multiple times across different queries, one for the overall topic and one for each chapter that matches a specific search.
The manual alternative is writing chapters by hand, which means re-watching your own video and noting timecodes. For a 30-minute tutorial that is tedious work. For a library of hundreds of videos it is impractical. A transcript-based pipeline removes the manual step entirely.
Step 1: Get the YouTube Transcript with Timestamps
The foundation of the pipeline is a clean, timestamped transcript. The YouTube Transcriber API returns one with a single GET request.
curl "https://getyoutubetranscriber.com/api/v2/transcript?video_url=dQw4w9WgXcQ" \
-H "Authorization: Bearer ytt_your_key_here"
The response looks like this:
{
"video_id": "dQw4w9WgXcQ",
"language": "en",
"transcript": [
{ "text": "Welcome back to the channel.", "start": 0, "duration": 2100 },
{ "text": "Today we are covering deployment.", "start": 2100, "duration": 3400 },
{ "text": "Let me pull up the terminal.", "start": 5500, "duration": 2200 }
],
"metadata": {
"title": "Deploy Your First App",
"author_name": "Example Dev",
"author_url": "https://www.youtube.com/@exampledev",
"thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"
}
}
Each segment carries a start value in milliseconds and a duration in milliseconds. That is the data you will use to build chapter markers.
By default include_timestamp is true, so you get the timing fields automatically. The lang parameter lets you pull transcripts in any of the 125+ supported languages, which matters if you want chapters on non-English content.
For patterns on storing this data once you have it, the post on designing a database schema for YouTube transcript data covers a solid approach.
Step 2: Group Segments into Topic Windows
A raw transcript has hundreds of small segments. Sending each one to an LLM individually would be expensive and miss context. The better approach is to group consecutive segments into windows of roughly 60 to 120 seconds, then ask the model to label each window.
Here is a Python function that groups segments by duration:
def group_segments(transcript, window_seconds=90):
windows = []
current_window = []
current_start = transcript[0]["start"]
current_duration = 0
for seg in transcript:
current_window.append(seg)
current_duration += seg["duration"]
if current_duration >= window_seconds * 1000:
windows.append({
"start_ms": current_start,
"text": " ".join(s["text"] for s in current_window)
})
current_window = []
current_start = seg["start"] + seg["duration"]
current_duration = 0
if current_window:
windows.append({
"start_ms": current_start,
"text": " ".join(s["text"] for s in current_window)
})
return windows
Each window records the start_ms of its first segment. That value is what you will use later when building the chapter list.
Step 3: Prompt an LLM to Label Each Segment
With your windows grouped, send them to an LLM and ask for a short chapter title per window. The key constraint: tell the model to return only labels, indexed by position. Do not ask it to generate timestamps. LLMs are unreliable at reproducing exact numbers from context, and asking for timestamps invites hallucinated values.
import openai, json
def generate_chapter_labels(windows):
numbered = "\n".join(
f"[{i}] {w['text'][:400]}" for i, w in enumerate(windows)
)
prompt = f"""You are generating YouTube chapter titles.
For each numbered transcript window below, write a short chapter title (3-6 words).
Return a JSON array of strings, one per window, in order.
Do not include timestamps or window numbers in the titles.
{numbered}
"""
response = openai.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"}
)
data = json.loads(response.choices[0].message.content)
# The model returns {"titles": [...]} or similar
labels = data.get("titles") or list(data.values())[0]
return labels
Trim each window's text to 400 characters or so before sending. This keeps token usage predictable and avoids hitting context limits on long videos.
Step 4: Convert Milliseconds to YouTube Timestamp Format
YouTube's chapter format expects M:SS or H:MM:SS. The start values from the API are milliseconds, so you need a conversion step.
def ms_to_timestamp(ms):
total_seconds = ms // 1000
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
if hours > 0:
return f"{hours}:{minutes:02d}:{seconds:02d}"
return f"{minutes}:{seconds:02d}"
def build_chapter_description(windows, labels):
chapters = []
for i, (window, label) in enumerate(zip(windows, labels)):
ts = ms_to_timestamp(window["start_ms"])
# YouTube requires the first chapter to start at 0:00
if i == 0:
ts = "0:00"
chapters.append(f"{ts} {label}")
return "\n".join(chapters)
A full 45-minute tutorial might produce output like:
0:00 Introduction and Overview
1:32 Setting Up the Environment
8:14 Writing the First Function
15:40 Error Handling Patterns
24:05 Testing Your Code
33:18 Deployment to Production
41:50 Wrapping Up
Paste this block into the video description, and YouTube will activate the chapter progress bar automatically.

Step 5: Output the Complete Pipeline in JavaScript
For teams running Node.js pipelines, here is the same flow end to end:
const fetch = require("node-fetch");
const OpenAI = require("openai");
const client = new OpenAI();
async function getTranscript(videoId, apiKey) {
const res = await fetch(
`https://getyoutubetranscriber.com/api/v2/transcript?video_url=${videoId}`,
{ headers: { Authorization: `Bearer ${apiKey}` } }
);
const data = await res.json();
return data.transcript;
}
function groupSegments(transcript, windowMs = 90000) {
const windows = [];
let current = [];
let startMs = transcript[0].start;
let accumulated = 0;
for (const seg of transcript) {
current.push(seg.text);
accumulated += seg.duration;
if (accumulated >= windowMs) {
windows.push({ start_ms: startMs, text: current.join(" ") });
startMs = seg.start + seg.duration;
accumulated = 0;
current = [];
}
}
if (current.length > 0) {
windows.push({ start_ms: startMs, text: current.join(" ") });
}
return windows;
}
function msToTimestamp(ms) {
const s = Math.floor(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
return `${m}:${String(sec).padStart(2, "0")}`;
}
async function generateChapters(videoId, apiKey) {
const transcript = await getTranscript(videoId, apiKey);
const windows = groupSegments(transcript);
const numbered = windows
.map((w, i) => `[${i}] ${w.text.slice(0, 400)}`)
.join("\n");
const completion = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "user",
content: `Label each window with a 3-6 word YouTube chapter title. Return a JSON array of strings.\n\n${numbered}`
}
],
response_format: { type: "json_object" }
});
const labels = Object.values(JSON.parse(completion.choices[0].message.content))[0];
return windows
.map((w, i) => {
const ts = i === 0 ? "0:00" : msToTimestamp(w.start_ms);
return `${ts} ${labels[i]}`;
})
.join("\n");
}
generateChapters("dQw4w9WgXcQ", "ytt_your_key_here").then(console.log);
Gotcha: Aligning LLM Labels to Real Timecodes
The most common failure mode in this pipeline is timestamp drift. It happens when developers ask the LLM to return timestamps alongside the titles. The model may reproduce the numbers from the prompt correctly, invent plausible-sounding ones, or slightly misread them. Any error compounds: one wrong timestamp shifts every subsequent chapter.
The fix used above is simple: never ask the model for a timestamp. Ask for indexed labels only, then look up the start_ms from the corresponding window object in your own code. The LLM handles language; your code handles numbers.
A second gotcha involves short videos. If the transcript is under three minutes, a 90-second window size will produce fewer than three windows, and YouTube requires at least three chapters to activate the feature. Add a guard that reduces the window size or skips chapter generation for short content:
MIN_CHAPTERS = 3
MIN_WINDOW_SECONDS = 20
def safe_window_size(transcript_duration_ms, target_chapters=6):
window = transcript_duration_ms // (target_chapters * 1000)
return max(window, MIN_WINDOW_SECONDS)
A third issue is the hard requirement that the first timestamp is exactly 0:00. If the first transcript segment starts at, say, 320 milliseconds, converting that with ms_to_timestamp gives 0:00 anyway due to integer floor division. But it is worth making the override explicit, as the code above does.
For handling cases where a transcript is unavailable or auto-captions are disabled, the post on handling missing or disabled YouTube transcripts gracefully covers the relevant fallback patterns.
Scaling to Batch Processing
If you are generating chapters across a large video library, the same transcript endpoint handles bulk work. Loop over video IDs, fetch each transcript, run the grouping and LLM labeling steps, and write the chapter text back to a database or pass it to the YouTube Data API v3 to update descriptions programmatically.
Rate limiting the LLM calls is usually the bottleneck, not the transcript fetches. A simple token bucket or a asyncio.Semaphore in Python keeps you within your model provider's limits.
For pipelines that also need to summarize or extract other content from the same transcripts, the approach in feeding YouTube transcripts to GPT and Claude for analysis is a natural companion to this one. You can run the chapter generator and a summarizer in parallel over the same transcript payload.
Start Building
The YouTube Transcriber API gives you 100 free credits with no credit card required. A typical transcript request costs one credit, so you can prototype the full chapter pipeline across 100 videos before paying anything. Sign up and grab your API key at getyoutubetranscriber.com, then run the code above against a real video to see the output. Once the chapter text looks right, plugging it into your content workflow or the YouTube Data API is the last step.