You record a product walkthrough, share the embed code, and a teammate drops it into a Notion page. The video plays fine. But the subtitles that show up on the watch page? Gone. The embed page never had them.
SendRec transcribes every uploaded video using Whisper and generates a VTT subtitle file. The full watch page has had a <track> element since launch. The lightweight embed player — designed for iframes — was built later, and the subtitle support was never ported over. A one-line gap in a SQL query that meant embedded videos were always silent-only.
How subtitles work in SendRec
When a video finishes uploading, a background worker picks it up from the transcription queue. It downloads the video from S3, runs Whisper, and produces two things: a JSON transcript (used for search and the transcript panel) and a VTT file (used for browser-native subtitles).
The VTT file gets uploaded to S3 alongside the video:
recordings/{userID}/{shareToken}.vtt
When the watch page renders, it queries the transcript_key column, generates a presigned S3 URL, and injects a <track> element:
<video controls>
<track kind="subtitles" src="https://storage.example.com/recordings/u1/abc.vtt?signature=..."
srclang="en" label="Subtitles" default>
</video>
The browser handles the rest. The viewer clicks the CC button in the video controls, and subtitles appear. No JavaScript subtitle renderer, no custom overlay — just the browser's built-in support.
What the embed page was missing
The embed page is a separate Go template in embed_page.go. It's intentionally minimal — no transcript panel, no comments, no emoji reactions. Just the video, a footer with the title, and a link back to the full watch page.
The original SQL query fetched 14 columns:
SELECT v.id, v.title, v.file_key, u.name, v.created_at, v.share_expires_at,
v.thumbnail_key, v.share_password, v.content_type,
v.user_id, u.email, v.view_notification,
v.cta_text, v.cta_url
FROM videos v
JOIN users u ON u.id = v.user_id
WHERE v.share_token = $1 AND v.status IN ('ready', 'processing')
No transcript_key. The data was in the database, the VTT file was in S3, but the embed page never asked for it.
The fix
Add v.transcript_key to the query:
SELECT v.id, v.title, v.file_key, u.name, v.created_at, v.share_expires_at,
v.thumbnail_key, v.share_password, v.content_type,
v.user_id, u.email, v.view_notification,
v.cta_text, v.cta_url, v.transcript_key
FROM videos v
JOIN users u ON u.id = v.user_id
WHERE v.share_token = $1 AND v.status IN ('ready', 'processing')
Scan it into a nullable string:
var transcriptKey *string
// ... in the Scan call:
&ctaText, &ctaUrl, &transcriptKey)
Generate a presigned URL if the key exists:
var transcriptURL string
if transcriptKey != nil {
if u, err := h.storage.GenerateDownloadURL(
r.Context(), *transcriptKey, 1*time.Hour,
); err == nil {
transcriptURL = u
}
}
And add the <track> element to the template, conditional on the URL being set:
<video controls playsinline webkit-playsinline crossorigin="anonymous"
controlsList="nodownload" src="{{.VideoURL}}"
{{if .ThumbnailURL}} poster="{{.ThumbnailURL}}"{{end}}>
{{if .TranscriptURL}}
<track kind="subtitles" src="{{.TranscriptURL}}"
srclang="en" label="Subtitles">
{{end}}
</video>
The crossorigin="anonymous" attribute was already on the video element (required for CORS-compatible S3 URLs). Without it, browsers block the VTT fetch because it's a cross-origin resource loaded by a media element.
Why subtitles and not captions
The <track> element supports two similar kinds: subtitles and captions. Subtitles are a text version of the dialogue. Captions include non-speech information too — sound effects, music cues, speaker identification.
Whisper generates a speech-to-text transcription. It doesn't annotate sound effects or identify speakers. That makes subtitles the correct kind. Using captions would promise more than the track delivers.
What about the default attribute?
The watch page uses <track ... default>, which tells the browser to enable subtitles automatically. The embed page doesn't. Embedded videos autoplay muted — having subtitles pop up immediately in an iframe that someone scrolls past would be distracting. The viewer can enable them manually through the CC button if they want them.
The graceful fallback
If a video hasn't been transcribed yet (the worker hasn't processed it, or transcription failed), transcript_key is NULL in the database. The if transcriptKey != nil check means the presigned URL generation is skipped, TranscriptURL stays empty, and the {{if .TranscriptURL}} template conditional means no <track> element is rendered.
No subtitle track, no CC button in the player controls. The viewer sees the same embed they saw before this change. The feature is invisible when there's nothing to show.
Try it
SendRec is open source (AGPL-3.0) and self-hostable. Closed captions in embeds are live at app.sendrec.eu — upload a video, wait for transcription to complete, then use the embed code from the Library. The CC button will appear in the embedded player's controls.
Top comments (0)