@growth-labs/video 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+ import type { Chapter, ChaptersFile } from '../types.js'
2
+ import { parseVtt } from './captions.js'
3
+
4
+ export function parseChaptersJson(raw: unknown): ChaptersFile | null {
5
+ if (!raw || typeof raw !== 'object') return null
6
+ const obj = raw as Record<string, unknown>
7
+ if (!Array.isArray(obj.chapters)) return null
8
+ const out: Chapter[] = []
9
+ for (const item of obj.chapters) {
10
+ if (!item || typeof item !== 'object') return null
11
+ const ch = item as Record<string, unknown>
12
+ if (
13
+ typeof ch.index !== 'number' ||
14
+ typeof ch.title !== 'string' ||
15
+ typeof ch.startSeconds !== 'number' ||
16
+ typeof ch.endSeconds !== 'number'
17
+ ) {
18
+ return null
19
+ }
20
+ out.push({
21
+ index: ch.index,
22
+ title: ch.title,
23
+ startSeconds: ch.startSeconds,
24
+ endSeconds: ch.endSeconds,
25
+ })
26
+ }
27
+ return { chapters: out }
28
+ }
29
+
30
+ export function parseChaptersVtt(input: string): ChaptersFile | null {
31
+ const cues = parseVtt(input)
32
+ if (!cues) return null
33
+ return {
34
+ chapters: cues.map((cue, index) => ({
35
+ index,
36
+ title: cue.text.trim(),
37
+ startSeconds: cue.startSeconds,
38
+ endSeconds: cue.endSeconds,
39
+ })),
40
+ }
41
+ }
42
+
43
+ export interface LoadChaptersOptions {
44
+ fetcher?: typeof fetch
45
+ }
46
+
47
+ export async function loadChapters(
48
+ url: string,
49
+ opts: LoadChaptersOptions = {},
50
+ ): Promise<ChaptersFile | null> {
51
+ const fetcher = opts.fetcher ?? fetch
52
+ try {
53
+ const res = await fetcher(url)
54
+ if (!res.ok) return null
55
+ const body = await res.text()
56
+ if (body.trimStart().startsWith('{')) {
57
+ return parseChaptersJson(JSON.parse(body) as unknown)
58
+ }
59
+ return parseChaptersVtt(body)
60
+ } catch {
61
+ return null
62
+ }
63
+ }
@@ -0,0 +1,9 @@
1
+ export * from './captions.js'
2
+ export * from './chapters.js'
3
+ export * from './ingest.js'
4
+ export * from './jsonld.js'
5
+ export * from './keys.js'
6
+ export * from './session.js'
7
+ export * from './text-tracks.js'
8
+ export * from './url.js'
9
+ export * from './youtube.js'
@@ -0,0 +1,220 @@
1
+ import { normalizeVideoId, posterKey } from './keys.js'
2
+ import { manifestUrl, posterUrl } from './url.js'
3
+
4
+ export type VideoR2PutBody =
5
+ | string
6
+ | ArrayBuffer
7
+ | ArrayBufferView
8
+ | Blob
9
+ | ReadableStream<Uint8Array>
10
+
11
+ export interface VideoR2Bucket {
12
+ put(
13
+ key: string,
14
+ value: VideoR2PutBody,
15
+ options?: {
16
+ httpMetadata?: { contentType?: string }
17
+ customMetadata?: Record<string, string>
18
+ },
19
+ ): Promise<R2Object | undefined>
20
+ }
21
+
22
+ export interface HlsBundleFile {
23
+ path: string
24
+ body: VideoR2PutBody
25
+ contentType?: string
26
+ customMetadata?: Record<string, string>
27
+ }
28
+
29
+ export interface ValidatedHlsBundleFile extends HlsBundleFile {
30
+ hlsPath: string
31
+ }
32
+
33
+ export interface ValidatedHlsBundle {
34
+ files: ValidatedHlsBundleFile[]
35
+ referencedPaths: string[]
36
+ }
37
+
38
+ export interface IngestHlsBundleOptions {
39
+ bucket: VideoR2Bucket
40
+ publicDomain: string
41
+ videoId: string
42
+ files: HlsBundleFile[]
43
+ poster?: HlsBundleFile
44
+ }
45
+
46
+ export interface HlsBundleIngestResult {
47
+ videoId: string
48
+ manifestUrl: string
49
+ posterUrl?: string
50
+ }
51
+
52
+ function assertSafeSegments(path: string): string {
53
+ const segments = path.split('/').filter(Boolean)
54
+ if (
55
+ path.length === 0 ||
56
+ path.startsWith('/') ||
57
+ segments.length === 0 ||
58
+ segments.some((segment) => segment === '.' || segment === '..')
59
+ ) {
60
+ throw new Error(`invalid HLS path: ${path}`)
61
+ }
62
+ return segments.join('/')
63
+ }
64
+
65
+ function normalizeHlsPath(rawPath: string): string {
66
+ let path = rawPath.trim().replace(/\\/g, '/').replace(/^\/+/, '')
67
+ if (path.startsWith('video/')) {
68
+ const segments = path.split('/')
69
+ const hlsIndex = segments.indexOf('hls')
70
+ if (hlsIndex >= 0) path = segments.slice(hlsIndex + 1).join('/')
71
+ }
72
+ if (path.startsWith('hls/')) path = path.slice('hls/'.length)
73
+ return assertSafeSegments(path)
74
+ }
75
+
76
+ function directoryOf(path: string): string {
77
+ const index = path.lastIndexOf('/')
78
+ return index === -1 ? '' : path.slice(0, index)
79
+ }
80
+
81
+ function stripQueryAndHash(uri: string): string {
82
+ return uri.split(/[?#]/, 1)[0] ?? ''
83
+ }
84
+
85
+ function resolveManifestUri(manifestPath: string, uri: string): string {
86
+ const cleanUri = stripQueryAndHash(uri.trim())
87
+ if (/^[a-z][a-z0-9+.-]*:/i.test(cleanUri) || cleanUri.startsWith('//')) {
88
+ throw new Error(`external HLS URI is not supported: ${uri}`)
89
+ }
90
+ const baseDir = directoryOf(manifestPath)
91
+ return normalizeHlsPath(baseDir ? `${baseDir}/${cleanUri}` : cleanUri)
92
+ }
93
+
94
+ function extractUriAttributes(line: string): string[] {
95
+ return [...line.matchAll(/\bURI="([^"]*)"/g)].map((match) => match[1] ?? '')
96
+ }
97
+
98
+ function extractReferencedUris(manifestPath: string, manifest: string): string[] {
99
+ const refs: string[] = []
100
+ for (const rawLine of manifest.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')) {
101
+ const line = rawLine.trim()
102
+ if (!line) continue
103
+ if (line.startsWith('#')) {
104
+ for (const uri of extractUriAttributes(line)) {
105
+ refs.push(resolveManifestUri(manifestPath, uri))
106
+ }
107
+ continue
108
+ }
109
+ refs.push(resolveManifestUri(manifestPath, line))
110
+ }
111
+ return refs
112
+ }
113
+
114
+ function readManifestText(file: HlsBundleFile): string {
115
+ if (typeof file.body === 'string') return file.body
116
+ if (file.body instanceof Uint8Array) return new TextDecoder().decode(file.body)
117
+ if (file.body instanceof ArrayBuffer) return new TextDecoder().decode(file.body)
118
+ if (ArrayBuffer.isView(file.body)) {
119
+ return new TextDecoder().decode(file.body)
120
+ }
121
+ throw new Error(`manifest ${file.path} must be text`)
122
+ }
123
+
124
+ async function bodyToText(body: VideoR2PutBody): Promise<string> {
125
+ if (typeof body === 'string') return body
126
+ if (body instanceof Blob) return body.text()
127
+ if (body instanceof Uint8Array) return new TextDecoder().decode(body)
128
+ if (body instanceof ArrayBuffer) return new TextDecoder().decode(body)
129
+ if (ArrayBuffer.isView(body)) return new TextDecoder().decode(body)
130
+ throw new Error('manifest body must be text, Blob, or ArrayBuffer')
131
+ }
132
+
133
+ async function prepareFiles(files: HlsBundleFile[]): Promise<HlsBundleFile[]> {
134
+ return Promise.all(
135
+ files.map(async (file) => {
136
+ const hlsPath = normalizeHlsPath(file.path)
137
+ if (!hlsPath.endsWith('.m3u8')) return file
138
+ return { ...file, body: await bodyToText(file.body) }
139
+ }),
140
+ )
141
+ }
142
+
143
+ function inferContentType(path: string, provided?: string): string {
144
+ if (provided) return provided
145
+ if (path.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl'
146
+ if (path.endsWith('.m4s')) return 'video/iso.segment'
147
+ if (path.endsWith('.mp4')) return 'video/mp4'
148
+ if (path.endsWith('.vtt')) return 'text/vtt; charset=utf-8'
149
+ if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg'
150
+ if (path.endsWith('.png')) return 'image/png'
151
+ if (path.endsWith('.webp')) return 'image/webp'
152
+ return 'application/octet-stream'
153
+ }
154
+
155
+ export function validateHlsBundle(files: HlsBundleFile[]): ValidatedHlsBundle {
156
+ const normalizedFiles = files.map((file) => ({ ...file, hlsPath: normalizeHlsPath(file.path) }))
157
+ const fileMap = new Map<string, ValidatedHlsBundleFile>()
158
+ for (const file of normalizedFiles) {
159
+ if (fileMap.has(file.hlsPath)) throw new Error(`duplicate HLS path: ${file.hlsPath}`)
160
+ fileMap.set(file.hlsPath, file)
161
+ }
162
+
163
+ const master = fileMap.get('master.m3u8')
164
+ if (!master) throw new Error('HLS bundle must include master.m3u8')
165
+
166
+ const referenced = new Set<string>()
167
+ const visitedManifests = new Set<string>()
168
+ const visitManifest = (manifestPath: string) => {
169
+ if (visitedManifests.has(manifestPath)) return
170
+ visitedManifests.add(manifestPath)
171
+ const manifestFile = fileMap.get(manifestPath)
172
+ if (!manifestFile) throw new Error(`missing HLS manifest: ${manifestPath}`)
173
+ const manifest = readManifestText(manifestFile)
174
+ if (!manifest.trimStart().startsWith('#EXTM3U')) {
175
+ throw new Error(`invalid HLS manifest: ${manifestPath}`)
176
+ }
177
+ for (const ref of extractReferencedUris(manifestPath, manifest)) {
178
+ referenced.add(ref)
179
+ const target = fileMap.get(ref)
180
+ if (!target) throw new Error(`missing HLS referenced file: ${ref}`)
181
+ if (ref.endsWith('.m3u8')) visitManifest(ref)
182
+ }
183
+ }
184
+
185
+ visitManifest('master.m3u8')
186
+
187
+ return { files: normalizedFiles, referencedPaths: [...referenced].sort() }
188
+ }
189
+
190
+ export async function ingestHlsBundle(
191
+ options: IngestHlsBundleOptions,
192
+ ): Promise<HlsBundleIngestResult> {
193
+ const videoId = normalizeVideoId(options.videoId)
194
+ const preparedFiles = await prepareFiles(options.files)
195
+ const bundle = validateHlsBundle(preparedFiles)
196
+
197
+ await Promise.all(
198
+ bundle.files.map((file) =>
199
+ options.bucket.put(`video/${videoId}/hls/${file.hlsPath}`, file.body, {
200
+ httpMetadata: { contentType: inferContentType(file.hlsPath, file.contentType) },
201
+ customMetadata: file.customMetadata,
202
+ }),
203
+ ),
204
+ )
205
+
206
+ if (options.poster) {
207
+ await options.bucket.put(posterKey(videoId), options.poster.body, {
208
+ httpMetadata: {
209
+ contentType: inferContentType(options.poster.path, options.poster.contentType),
210
+ },
211
+ customMetadata: options.poster.customMetadata,
212
+ })
213
+ }
214
+
215
+ return {
216
+ videoId,
217
+ manifestUrl: manifestUrl(options.publicDomain, videoId),
218
+ posterUrl: options.poster ? posterUrl(options.publicDomain, videoId) : undefined,
219
+ }
220
+ }
@@ -0,0 +1,42 @@
1
+ import type { VideoObjectInput } from '../types.js'
2
+ import { posterUrl } from './url.js'
3
+
4
+ function toIsoDuration(seconds: number): string {
5
+ const h = Math.floor(seconds / 3600)
6
+ const m = Math.floor((seconds % 3600) / 60)
7
+ const s = seconds % 60
8
+ let out = 'PT'
9
+ if (h) out += `${h}H`
10
+ if (m) out += `${m}M`
11
+ if (s) out += `${s}S`
12
+ if (out === 'PT') out = 'PT0S'
13
+ return out
14
+ }
15
+
16
+ export function generateVideoObjectJsonLd(input: VideoObjectInput): object {
17
+ const thumbnailUrl = input.thumbnailUrls ?? [posterUrl(input.publicDomain, input.videoId)]
18
+
19
+ const ld: Record<string, unknown> = {
20
+ '@context': 'https://schema.org',
21
+ '@type': 'VideoObject',
22
+ name: input.title,
23
+ description: input.description,
24
+ uploadDate: input.uploadDate,
25
+ duration: toIsoDuration(input.durationSeconds),
26
+ thumbnailUrl,
27
+ embedUrl: input.embedUrl,
28
+ }
29
+
30
+ if (input.contentUrl) ld.contentUrl = input.contentUrl
31
+ if (input.transcript) ld.transcript = input.transcript
32
+
33
+ if (input.chapters && input.chapters.length > 0) {
34
+ ld.potentialAction = {
35
+ '@type': 'SeekToAction',
36
+ target: `${input.embedUrl}#t={seek_to_second_number}`,
37
+ 'startOffset-input': 'required name=seek_to_second_number',
38
+ }
39
+ }
40
+
41
+ return ld
42
+ }
@@ -0,0 +1,42 @@
1
+ export function normalizeVideoId(videoId: string): string {
2
+ const normalized = videoId.trim()
3
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(normalized)) {
4
+ throw new Error('invalid videoId')
5
+ }
6
+ return normalized
7
+ }
8
+
9
+ export function videoManifestKey(videoId: string): string {
10
+ const safeVideoId = normalizeVideoId(videoId)
11
+ return `video/${safeVideoId}/hls/master.m3u8`
12
+ }
13
+
14
+ export function posterKey(videoId: string): string {
15
+ const safeVideoId = normalizeVideoId(videoId)
16
+ return `video/${safeVideoId}/poster.jpg`
17
+ }
18
+
19
+ export function captionsKey(videoId: string, lang: string = 'en'): string {
20
+ const safeVideoId = normalizeVideoId(videoId)
21
+ const normalized =
22
+ lang
23
+ .trim()
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9-]/g, '_') || 'en'
26
+ return `video/${safeVideoId}/captions_${normalized}.vtt`
27
+ }
28
+
29
+ export function chaptersKey(videoId: string): string {
30
+ const safeVideoId = normalizeVideoId(videoId)
31
+ return `video/${safeVideoId}/chapters.vtt`
32
+ }
33
+
34
+ export function chaptersJsonKey(videoId: string): string {
35
+ const safeVideoId = normalizeVideoId(videoId)
36
+ return `video/${safeVideoId}/chapters.json`
37
+ }
38
+
39
+ export function hlsBundlePrefix(videoId: string): string {
40
+ const safeVideoId = normalizeVideoId(videoId)
41
+ return `video/${safeVideoId}/hls/`
42
+ }
@@ -0,0 +1,96 @@
1
+ export interface SessionPayload {
2
+ videoIds: string[] | '*'
3
+ subjectId: string
4
+ issuedAt: number
5
+ expiresAt: number
6
+ nonce: string
7
+ }
8
+
9
+ const encoder = new TextEncoder()
10
+ const decoder = new TextDecoder()
11
+
12
+ function toBase64Url(bytes: Uint8Array): string {
13
+ let bin = ''
14
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
15
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
16
+ }
17
+
18
+ function fromBase64Url(str: string): Uint8Array {
19
+ const padded = str.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (str.length % 4)) % 4)
20
+ const bin = atob(padded)
21
+ const bytes = new Uint8Array(bin.length)
22
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
23
+ return bytes
24
+ }
25
+
26
+ async function importKey(secret: string): Promise<CryptoKey> {
27
+ return crypto.subtle.importKey(
28
+ 'raw',
29
+ encoder.encode(secret),
30
+ { name: 'HMAC', hash: 'SHA-256' },
31
+ false,
32
+ ['sign', 'verify'],
33
+ )
34
+ }
35
+
36
+ async function sign(secret: string, data: string): Promise<string> {
37
+ const key = await importKey(secret)
38
+ const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(data))
39
+ return toBase64Url(new Uint8Array(sig))
40
+ }
41
+
42
+ async function verify(secret: string, data: string, signatureB64: string): Promise<boolean> {
43
+ const key = await importKey(secret)
44
+ const sig = fromBase64Url(signatureB64)
45
+ return crypto.subtle.verify('HMAC', key, sig.buffer as ArrayBuffer, encoder.encode(data))
46
+ }
47
+
48
+ function randomNonce(): string {
49
+ const bytes = new Uint8Array(16)
50
+ crypto.getRandomValues(bytes)
51
+ let hex = ''
52
+ for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0')
53
+ return hex
54
+ }
55
+
56
+ export async function issueSession(
57
+ payload: Omit<SessionPayload, 'nonce'>,
58
+ secret: string,
59
+ ): Promise<string> {
60
+ const full: SessionPayload = { ...payload, nonce: randomNonce() }
61
+ const payloadB64 = toBase64Url(encoder.encode(JSON.stringify(full)))
62
+ const sig = await sign(secret, payloadB64)
63
+ return `${payloadB64}.${sig}`
64
+ }
65
+
66
+ export async function verifySession(
67
+ cookie: string,
68
+ secret: string,
69
+ requestedVideoId: string,
70
+ ): Promise<SessionPayload | null> {
71
+ const dot = cookie.indexOf('.')
72
+ if (dot === -1) return null
73
+ const payloadB64 = cookie.slice(0, dot)
74
+ const sig = cookie.slice(dot + 1)
75
+ if (!payloadB64 || !sig) return null
76
+ if (!(await verify(secret, payloadB64, sig))) return null
77
+
78
+ let payload: SessionPayload
79
+ try {
80
+ payload = JSON.parse(decoder.decode(fromBase64Url(payloadB64))) as SessionPayload
81
+ } catch {
82
+ return null
83
+ }
84
+
85
+ const now = Math.floor(Date.now() / 1000)
86
+ if (payload.expiresAt <= now) return null
87
+
88
+ if (
89
+ payload.videoIds !== '*' &&
90
+ (!Array.isArray(payload.videoIds) || !payload.videoIds.includes(requestedVideoId))
91
+ ) {
92
+ return null
93
+ }
94
+
95
+ return payload
96
+ }
@@ -0,0 +1,100 @@
1
+ import type { VttCue } from '../types.js'
2
+ import { parseVtt } from './captions.js'
3
+ import { captionsKey, chaptersKey } from './keys.js'
4
+
5
+ export interface VideoTextTrackBucket {
6
+ put(
7
+ key: string,
8
+ value: string,
9
+ options?: { httpMetadata?: { contentType?: string } },
10
+ ): Promise<{ etag?: string } | undefined>
11
+ get(key: string): Promise<{ text(): Promise<string> } | null>
12
+ }
13
+
14
+ export interface VideoTextTrackWriteResult {
15
+ key: string
16
+ etag?: string
17
+ }
18
+
19
+ export interface VideoTextTrackReadResult {
20
+ key: string
21
+ vtt: string
22
+ cues: VttCue[]
23
+ }
24
+
25
+ export interface CaptionsVttOptions {
26
+ bucket: VideoTextTrackBucket
27
+ videoId: string
28
+ vtt: string
29
+ language?: string
30
+ }
31
+
32
+ export interface ReadCaptionsVttOptions {
33
+ bucket: VideoTextTrackBucket
34
+ videoId: string
35
+ language?: string
36
+ }
37
+
38
+ export interface ChaptersVttOptions {
39
+ bucket: VideoTextTrackBucket
40
+ videoId: string
41
+ vtt: string
42
+ }
43
+
44
+ export interface ReadChaptersVttOptions {
45
+ bucket: VideoTextTrackBucket
46
+ videoId: string
47
+ }
48
+
49
+ function assertValidVtt(vtt: string): VttCue[] {
50
+ const cues = parseVtt(vtt)
51
+ if (!cues) throw new Error('Expected valid WEBVTT text')
52
+ return cues
53
+ }
54
+
55
+ async function putVtt(
56
+ bucket: VideoTextTrackBucket,
57
+ key: string,
58
+ vtt: string,
59
+ ): Promise<VideoTextTrackWriteResult> {
60
+ assertValidVtt(vtt)
61
+ const result = await bucket.put(key, vtt, {
62
+ httpMetadata: { contentType: 'text/vtt; charset=utf-8' },
63
+ })
64
+ return { key, etag: result?.etag }
65
+ }
66
+
67
+ async function readVtt(
68
+ bucket: VideoTextTrackBucket,
69
+ key: string,
70
+ ): Promise<VideoTextTrackReadResult | null> {
71
+ const object = await bucket.get(key)
72
+ if (!object) return null
73
+ const vtt = await object.text()
74
+ const cues = assertValidVtt(vtt)
75
+ return { key, vtt, cues }
76
+ }
77
+
78
+ export async function writeCaptionsVtt(
79
+ options: CaptionsVttOptions,
80
+ ): Promise<VideoTextTrackWriteResult> {
81
+ return putVtt(options.bucket, captionsKey(options.videoId, options.language), options.vtt)
82
+ }
83
+
84
+ export async function readCaptionsVtt(
85
+ options: ReadCaptionsVttOptions,
86
+ ): Promise<VideoTextTrackReadResult | null> {
87
+ return readVtt(options.bucket, captionsKey(options.videoId, options.language))
88
+ }
89
+
90
+ export async function writeChaptersVtt(
91
+ options: ChaptersVttOptions,
92
+ ): Promise<VideoTextTrackWriteResult> {
93
+ return putVtt(options.bucket, chaptersKey(options.videoId), options.vtt)
94
+ }
95
+
96
+ export async function readChaptersVtt(
97
+ options: ReadChaptersVttOptions,
98
+ ): Promise<VideoTextTrackReadResult | null> {
99
+ return readVtt(options.bucket, chaptersKey(options.videoId))
100
+ }
@@ -0,0 +1,41 @@
1
+ export function manifestUrl(publicDomain: string, videoId: string): string {
2
+ return `https://${publicDomain}/video/${videoId}/hls/master.m3u8`
3
+ }
4
+
5
+ export function posterUrl(publicDomain: string, videoId: string): string {
6
+ return `https://${publicDomain}/cdn-cgi/image/width=1200,height=675,fit=cover,quality=80,format=auto/video/${videoId}/poster.jpg`
7
+ }
8
+
9
+ export function captionsUrl(publicDomain: string, videoId: string, lang: string = 'en'): string {
10
+ const normalized =
11
+ lang
12
+ .trim()
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9-]/g, '_') || 'en'
15
+ return `https://${publicDomain}/video/${videoId}/captions_${normalized}.vtt`
16
+ }
17
+
18
+ export function chaptersUrl(publicDomain: string, videoId: string): string {
19
+ return `https://${publicDomain}/video/${videoId}/chapters.vtt`
20
+ }
21
+
22
+ export function chaptersJsonUrl(publicDomain: string, videoId: string): string {
23
+ return `https://${publicDomain}/video/${videoId}/chapters.json`
24
+ }
25
+
26
+ function normalizeRoute(route: string): string {
27
+ return route.endsWith('/') ? route.slice(0, -1) : route
28
+ }
29
+
30
+ export function premiumManifestUrl(playbackRoute: string, videoId: string): string {
31
+ return `${normalizeRoute(playbackRoute)}/${videoId}/hls/master.m3u8`
32
+ }
33
+
34
+ export function premiumPlaybackUrl(
35
+ playbackRoute: string,
36
+ videoId: string,
37
+ subpath: string,
38
+ ): string {
39
+ const trimmed = subpath.startsWith('/') ? subpath.slice(1) : subpath
40
+ return `${normalizeRoute(playbackRoute)}/${videoId}/${trimmed}`
41
+ }
@@ -0,0 +1,30 @@
1
+ export interface YouTubeEmbedOptions {
2
+ autoplay?: boolean
3
+ start?: number
4
+ end?: number
5
+ muted?: boolean
6
+ controls?: boolean
7
+ privacyMode?: boolean
8
+ }
9
+
10
+ export function youtubeEmbedUrl(videoId: string, opts: YouTubeEmbedOptions = {}): string {
11
+ const host = opts.privacyMode === false ? 'www.youtube.com' : 'www.youtube-nocookie.com'
12
+ const params = new URLSearchParams()
13
+ if (opts.autoplay) params.set('autoplay', '1')
14
+ if (opts.muted) params.set('mute', '1')
15
+ if (opts.controls === false) params.set('controls', '0')
16
+ if (opts.start != null) params.set('start', String(opts.start))
17
+ if (opts.end != null) params.set('end', String(opts.end))
18
+ const qs = params.toString()
19
+ return `https://${host}/embed/${videoId}${qs ? `?${qs}` : ''}`
20
+ }
21
+
22
+ export type YouTubeThumbnailQuality = 'default' | 'hq' | 'maxres'
23
+
24
+ export function youtubeThumbnailUrl(
25
+ videoId: string,
26
+ quality: YouTubeThumbnailQuality = 'maxres',
27
+ ): string {
28
+ const suffix = quality === 'maxres' ? 'maxresdefault' : quality === 'hq' ? 'hqdefault' : 'default'
29
+ return `https://i.ytimg.com/vi/${videoId}/${suffix}.jpg`
30
+ }
@@ -0,0 +1,9 @@
1
+ declare module 'virtual:growth-labs/video/config' {
2
+ import type { ResolvedVideoOptions } from '@growth-labs/video'
3
+ export const config: ResolvedVideoOptions
4
+ }
5
+
6
+ declare module 'virtual:growth-labs/video/access-check' {
7
+ import type { AccessCheck } from '@growth-labs/video'
8
+ export const accessRegistry: { check?: AccessCheck }
9
+ }
@@ -0,0 +1,34 @@
1
+ import type { Plugin } from 'vite'
2
+ import type { ResolvedVideoOptions } from './options.js'
3
+
4
+ const VIRTUAL_MODULE_ID = 'virtual:growth-labs/video/config'
5
+ const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`
6
+ const ACCESS_CHECK_VIRTUAL_ID = 'virtual:growth-labs/video/access-check'
7
+ const RESOLVED_ACCESS_CHECK_VIRTUAL_ID = `\0${ACCESS_CHECK_VIRTUAL_ID}`
8
+
9
+ export function growthLabsVideoPlugin(config: ResolvedVideoOptions): Plugin {
10
+ return {
11
+ name: 'growth-labs-video-config',
12
+ resolveId(id) {
13
+ if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID
14
+ if (id === ACCESS_CHECK_VIRTUAL_ID) return RESOLVED_ACCESS_CHECK_VIRTUAL_ID
15
+ },
16
+ load(id) {
17
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) {
18
+ return `export const config = ${JSON.stringify(config)};`
19
+ }
20
+ if (id === RESOLVED_ACCESS_CHECK_VIRTUAL_ID) {
21
+ const spec = config.premium?.accessCheckModule
22
+ if (!spec) {
23
+ return 'export const accessRegistry = {};\n'
24
+ }
25
+ const local = '__gl_video_access_check_0'
26
+ return [
27
+ `import { ${spec.export} as ${local} } from ${JSON.stringify(spec.module)};`,
28
+ `export const accessRegistry = { check: ${local} };`,
29
+ '',
30
+ ].join('\n')
31
+ }
32
+ },
33
+ }
34
+ }