@growth-labs/video
v0.3.14
Published
Astro 6 integration for HLS video playback from R2, reusable R2 HLS bundle ingestion, captions/chapters sidecars, YouTube embeds, and optional Worker-proxied premium gating.
Downloads
2,160
Readme
@growth-labs/video
Astro 6 integration for HLS video playback from R2, reusable R2 HLS bundle ingestion, captions/chapters sidecars, YouTube embeds, and optional Worker-proxied premium gating.
Install
pnpm add @growth-labs/video vidstackConfig
import video from '@growth-labs/video'
video({
publicDomain: 'media.example.com',
r2Binding: 'MEDIA_BUCKET',
admin: {
importRoute: {
enabled: true,
path: '/api/admin/video/import',
requireAuth: true,
},
},
})Premium playback additionally needs premium.enabled, an accessCheckModule (a module-spec pointing at a file in your own codebase), and a GL_VIDEO_SESSION_SECRET secret of at least 32 bytes:
// astro.config.mjs
video({
publicDomain: 'media.example.com',
premium: {
enabled: true,
sessionScope: 'per-video', // default; 'wildcard' opts into the 0.2.x behavior
sessionTtl: '1h', // duration string: '1h' | '30m' | '2h30m' | '45s'
accessCheckModule: { module: '/src/lib/video-access-check', export: 'check' },
},
})// /src/lib/video-access-check.ts
import type { AccessCheck } from '@growth-labs/video'
export const check: AccessCheck = async ({ videoId, request, env }, context) => {
// Returns { allowed, reason?, subjectId? }
}Why a module spec instead of an inline function? The integration runs in the build-time Node process; the session route runs in the Worker runtime — a different process. An inline accessCheck registered at build time never reaches the Worker bundle, so every request would 500 with access_check_not_configured. The module-spec lets the Vite plugin emit a real static ES import at the top of the virtual:growth-labs/video/access-check module, so the function is bundled with the worker. Matches the same pattern as @growth-labs/auth's renderers (see auth 0.3.7+ MIGRATION.md).
per-video scope (the 0.3.0 default) issues a cookie restricted to one videoId, so video status changes propagate within sessionTtl. wildcard issues a cookie that plays every premium video for the cookie's lifetime — cheaper per page but status changes do not propagate until the cookie expires.
Customizing the layout
Since 0.3.5, <Video> exposes a named slot layout for replacing the Vidstack chrome (controls, sliders, menus) without losing anything the package wires up around the player — premium bootstrap, accessCheck, gl:video-* analytics events, captions/chapter <track> elements, progress marks.
---
import { Video } from '@growth-labs/video/components'
import MyVideoLayout from '../components/MyVideoLayout.astro'
---
<Video videoId="..." title="..." premium>
<MyVideoLayout slot="layout" />
</Video>If you omit the slot, you get Vidstack's stock <media-video-layout> (the package's current default). If you provide it, your slotted content replaces the default entirely — it is rendered as a direct child of <media-player>, so any Vidstack layout primitive (<media-controls>, <media-time-slider>, <media-quality-radio-group>, etc.) works.
Consumers writing a custom layout typically need to import Vidstack's full UI element registry once at the top of their component (Vidstack's vidstack/elements only defines a minimal set; the full primitive set is under vidstack/player/ui):
// inside MyVideoLayout.astro's <script>
import 'vidstack/player/ui'Captions and chapter <track> elements continue to be rendered by <Video> inside <media-provider> — do not duplicate them inside your slotted layout.
R2 Layout
video/<videoId>/hls/master.m3u8
video/<videoId>/hls/<rendition>/index.m3u8
video/<videoId>/hls/<rendition>/init.mp4
video/<videoId>/hls/<rendition>/segment-00001.m4s
video/<videoId>/poster.jpg
video/<videoId>/text/captions_en.vtt
video/<videoId>/text/chapters.vttIngest Helpers
import { ingestHlsBundle, writeCaptionsVtt, writeChaptersVtt } from '@growth-labs/video/utils'
await ingestHlsBundle({
bucket: env.MEDIA_BUCKET,
publicDomain: 'media.example.com',
videoId: 'intro-video',
files: [
{ path: 'master.m3u8', body: masterManifest },
{ path: 'h264/720p/index.m3u8', body: renditionManifest },
{ path: 'h264/720p/init.mp4', body: initBytes },
{ path: 'h264/720p/segment-00001.m4s', body: segmentBytes },
],
poster: { path: 'poster.jpg', body: posterBytes, contentType: 'image/jpeg' },
})
await writeCaptionsVtt({ bucket: env.MEDIA_BUCKET, videoId: 'intro-video', vtt: captionsVtt })
await writeChaptersVtt({ bucket: env.MEDIA_BUCKET, videoId: 'intro-video', vtt: chaptersVtt })The ingestion helper validates master.m3u8, referenced rendition playlists, segment paths, and local HLS URI="..." attributes such as EXT-X-MAP, EXT-X-MEDIA, EXT-X-I-FRAME-STREAM-INF, and EXT-X-KEY before writing to R2. Absolute/external URLs and unsafe relative paths are rejected. Transcoding is out of scope; pass already-generated HLS and VTT files.
All R2 key helpers normalize videoId with the same safe contract: IDs must start with an ASCII letter or number and then contain only ASCII letters, numbers, _, or -. Text-track helpers write only under video/<videoId>/text/captions_<lang>.vtt and video/<videoId>/text/chapters.vtt.
Injected routes read bindings from cloudflare:workers env; no locals.runtime shim is required.
