@growth-labs/video 0.3.4 → 0.3.6

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,72 @@
1
+ import type { VttCue } from '../types.js'
2
+
3
+ function parseTimestamp(str: string): number | null {
4
+ const m = str.match(/^(?:(\d+):)?(\d{2}):(\d{2})\.(\d{3})$/)
5
+ if (!m) return null
6
+ const h = m[1] ? parseInt(m[1], 10) : 0
7
+ const mm = parseInt(m[2], 10)
8
+ const s = parseInt(m[3], 10)
9
+ const ms = parseInt(m[4], 10)
10
+ return h * 3600 + mm * 60 + s + ms / 1000
11
+ }
12
+
13
+ export function parseVtt(input: string): VttCue[] | null {
14
+ if (!input) return null
15
+ const normalized = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
16
+ const lines = normalized.split('\n')
17
+ if (!lines[0].startsWith('WEBVTT')) return null
18
+
19
+ const cues: VttCue[] = []
20
+ let i = 1
21
+ while (i < lines.length) {
22
+ while (i < lines.length && lines[i].trim() === '') i++
23
+ if (i >= lines.length) break
24
+
25
+ // Optional cue identifier line — skip if it's not a timing line.
26
+ if (!lines[i].includes('-->')) {
27
+ i++
28
+ if (i >= lines.length) break
29
+ }
30
+
31
+ const timing = lines[i]
32
+ const mt = timing.match(/^([\d:.]+)\s+-->\s+([\d:.]+)/)
33
+ if (!mt) {
34
+ i++
35
+ continue
36
+ }
37
+ const start = parseTimestamp(mt[1])
38
+ const end = parseTimestamp(mt[2])
39
+ i++
40
+
41
+ const textLines: string[] = []
42
+ while (i < lines.length && lines[i].trim() !== '') {
43
+ textLines.push(lines[i])
44
+ i++
45
+ }
46
+
47
+ if (start == null || end == null) continue
48
+ cues.push({ startSeconds: start, endSeconds: end, text: textLines.join('\n') })
49
+ }
50
+
51
+ if (cues.length === 0) return null
52
+ return cues
53
+ }
54
+
55
+ export interface LoadCaptionsOptions {
56
+ fetcher?: typeof fetch
57
+ }
58
+
59
+ export async function loadCaptions(
60
+ url: string,
61
+ opts: LoadCaptionsOptions = {},
62
+ ): Promise<VttCue[] | null> {
63
+ const fetcher = opts.fetcher ?? fetch
64
+ try {
65
+ const res = await fetcher(url)
66
+ if (!res.ok) return null
67
+ const body = await res.text()
68
+ return parseVtt(body)
69
+ } catch {
70
+ return null
71
+ }
72
+ }
@@ -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
+ }