@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.
- package/package.json +9 -5
- package/src/cloudflare-workers.d.ts +3 -0
- package/src/components/Video.astro +1 -0
- package/src/index.ts +129 -0
- package/src/options.ts +119 -0
- package/src/routes/import.ts +92 -0
- package/src/routes/playback.ts +101 -0
- package/src/routes/session.ts +85 -0
- package/src/types.ts +46 -0
- package/src/utils/captions.ts +72 -0
- package/src/utils/chapters.ts +63 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/ingest.ts +220 -0
- package/src/utils/jsonld.ts +42 -0
- package/src/utils/keys.ts +42 -0
- package/src/utils/session.ts +96 -0
- package/src/utils/text-tracks.ts +100 -0
- package/src/utils/url.ts +41 -0
- package/src/utils/youtube.ts +30 -0
- package/src/virtual.d.ts +9 -0
- package/src/vite-plugin.ts +34 -0
|
@@ -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
|
+
}
|
package/src/utils/url.ts
ADDED
|
@@ -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
|
+
}
|
package/src/virtual.d.ts
ADDED
|
@@ -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
|
+
}
|