@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/video",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -31,14 +31,18 @@
31
31
  },
32
32
  "files": [
33
33
  "dist",
34
- "src/components"
34
+ "src/**/*.ts",
35
+ "src/**/*.astro",
36
+ "src/**/*.css",
37
+ "src/**/*.md",
38
+ "src/**/*.sql"
35
39
  ],
36
40
  "publishConfig": {
37
41
  "access": "public",
38
42
  "registry": "https://registry.npmjs.org/"
39
43
  },
40
44
  "peerDependencies": {
41
- "astro": "^6.0.0",
45
+ "astro": "^6.1.10",
42
46
  "vidstack": "^1.12.0"
43
47
  },
44
48
  "peerDependenciesMeta": {
@@ -51,9 +55,9 @@
51
55
  },
52
56
  "devDependencies": {
53
57
  "@cloudflare/workers-types": "^4.20260402.1",
54
- "astro": "^6.0.0",
58
+ "astro": "^6.1.10",
55
59
  "typescript": "^5.7.0",
56
- "vite": "^7.0.0",
60
+ "vite": "^7.3.2",
57
61
  "vitest": "^3.0.0",
58
62
  "vidstack": "^1.12.0"
59
63
  },
@@ -0,0 +1,3 @@
1
+ declare module 'cloudflare:workers' {
2
+ export const env: Record<string, unknown>
3
+ }
@@ -72,6 +72,7 @@ const playerBoolAttrs = buildPlayerBooleanAttrs({
72
72
  src={premium ? undefined : src}
73
73
  poster={poster}
74
74
  preload={config.player.preload}
75
+ crossorigin="anonymous"
75
76
  class={`gl-video ${className ?? ''}`.trim()}
76
77
  {...playerBoolAttrs}
77
78
  >
package/src/index.ts ADDED
@@ -0,0 +1,129 @@
1
+ import type { AstroIntegration } from 'astro'
2
+ import { type VideoOptions, videoOptionsSchema } from './options.js'
3
+ import type { AccessCheck } from './types.js'
4
+ import { growthLabsVideoPlugin } from './vite-plugin.js'
5
+
6
+ /**
7
+ * Integration input. Mirrors `VideoOptions` (the Zod-validated shape). The
8
+ * legacy `accessCheck` inline function is accepted only to throw a clear
9
+ * migration error at integration init — it never worked in Cloudflare
10
+ * Workers production (build-time registration didn't survive to the
11
+ * runtime process). See MIGRATION.md.
12
+ */
13
+ export interface VideoIntegrationInput extends VideoOptions {
14
+ /**
15
+ * @deprecated since 0.3.3 — removed. Wire your access check through
16
+ * `premium.accessCheckModule: { module, export }` instead. The inline
17
+ * form never worked in Cloudflare Workers production.
18
+ */
19
+ accessCheck?: AccessCheck
20
+ }
21
+
22
+ export default function video(userOptions: VideoIntegrationInput): AstroIntegration {
23
+ const { accessCheck: inlineAccessCheck, ...optionsInput } = userOptions
24
+ const options = videoOptionsSchema.parse(optionsInput)
25
+
26
+ if (options.premium?.enabled) {
27
+ if (inlineAccessCheck !== undefined && !options.premium.accessCheckModule) {
28
+ throw new Error(
29
+ '@growth-labs/video: inline `accessCheck` function is no longer supported since 0.3.3. ' +
30
+ 'The inline form never worked in Cloudflare Workers production — build-time ' +
31
+ 'registration of the function did not survive to the runtime process, so ' +
32
+ '`/api/video/session` returned `500 access_check_not_registered` on every request. ' +
33
+ 'Move the function into its own module and reference it via ' +
34
+ "`premium.accessCheckModule: { module: '/src/lib/video-access-check', export: 'check' }`. " +
35
+ 'See MIGRATION.md for the migration.',
36
+ )
37
+ }
38
+ if (!options.premium.accessCheckModule) {
39
+ throw new Error(
40
+ '@growth-labs/video: premium.enabled=true but no `premium.accessCheckModule` was provided. ' +
41
+ 'Premium video must not be served without an entitlement check. Wire one as ' +
42
+ "`premium.accessCheckModule: { module: '/src/lib/video-access-check', export: 'check' }` " +
43
+ '(the module path is project-root absolute; the export must implement the `AccessCheck` type). ' +
44
+ 'See README + MIGRATION.md for the module shape, or set `premium.enabled=false`.',
45
+ )
46
+ }
47
+ }
48
+
49
+ return {
50
+ name: '@growth-labs/video',
51
+ hooks: {
52
+ 'astro:config:setup': ({ injectRoute, updateConfig }) => {
53
+ updateConfig({
54
+ vite: { plugins: [growthLabsVideoPlugin(options)] },
55
+ })
56
+
57
+ if (options.premium?.enabled) {
58
+ injectRoute({
59
+ pattern: options.premium.sessionRoute,
60
+ entrypoint: '@growth-labs/video/routes/session',
61
+ })
62
+ injectRoute({
63
+ pattern: `${options.premium.playbackRoute}/[...path]`,
64
+ entrypoint: '@growth-labs/video/routes/playback',
65
+ })
66
+ }
67
+
68
+ if (options.admin.importRoute.enabled) {
69
+ injectRoute({
70
+ pattern: options.admin.importRoute.path,
71
+ entrypoint: '@growth-labs/video/routes/import',
72
+ })
73
+ }
74
+ },
75
+ },
76
+ }
77
+ }
78
+
79
+ export type { ResolvedVideoOptions, VideoOptions } from './options.js'
80
+ export { videoOptionsSchema } from './options.js'
81
+ export type {
82
+ AccessCheck,
83
+ AccessCheckParams,
84
+ AccessResult,
85
+ Chapter,
86
+ ChaptersFile,
87
+ VideoObjectInput,
88
+ VttCue,
89
+ } from './types.js'
90
+ export { loadCaptions, parseVtt } from './utils/captions.js'
91
+ export { loadChapters, parseChaptersJson, parseChaptersVtt } from './utils/chapters.js'
92
+ export {
93
+ type HlsBundleFile,
94
+ type HlsBundleIngestResult,
95
+ ingestHlsBundle,
96
+ type ValidatedHlsBundle,
97
+ type ValidatedHlsBundleFile,
98
+ type VideoR2Bucket,
99
+ validateHlsBundle,
100
+ } from './utils/ingest.js'
101
+ export { generateVideoObjectJsonLd } from './utils/jsonld.js'
102
+ export {
103
+ captionsKey,
104
+ chaptersJsonKey,
105
+ chaptersKey,
106
+ hlsBundlePrefix,
107
+ posterKey,
108
+ videoManifestKey,
109
+ } from './utils/keys.js'
110
+ export { issueSession, verifySession } from './utils/session.js'
111
+ export {
112
+ readCaptionsVtt,
113
+ readChaptersVtt,
114
+ type VideoTextTrackBucket,
115
+ type VideoTextTrackReadResult,
116
+ type VideoTextTrackWriteResult,
117
+ writeCaptionsVtt,
118
+ writeChaptersVtt,
119
+ } from './utils/text-tracks.js'
120
+ export {
121
+ captionsUrl,
122
+ chaptersJsonUrl,
123
+ chaptersUrl,
124
+ manifestUrl,
125
+ posterUrl,
126
+ premiumManifestUrl,
127
+ premiumPlaybackUrl,
128
+ } from './utils/url.js'
129
+ export { youtubeEmbedUrl, youtubeThumbnailUrl } from './utils/youtube.js'
package/src/options.ts ADDED
@@ -0,0 +1,119 @@
1
+ import { z } from 'zod'
2
+
3
+ const SESSION_TTL_MIN_SECONDS = 60
4
+ const SESSION_TTL_MAX_SECONDS = 86_400
5
+
6
+ function parseDurationToSeconds(input: string): number | null {
7
+ if (typeof input !== 'string' || input.length === 0) return null
8
+ const match = input.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/)
9
+ if (!match) return null
10
+ const [, h, m, s] = match
11
+ if (h === undefined && m === undefined && s === undefined) return null
12
+ const hours = h ? Number.parseInt(h, 10) : 0
13
+ const minutes = m ? Number.parseInt(m, 10) : 0
14
+ const seconds = s ? Number.parseInt(s, 10) : 0
15
+ return hours * 3600 + minutes * 60 + seconds
16
+ }
17
+
18
+ export const videoOptionsSchema = z.object({
19
+ // ─── Required ───
20
+ publicDomain: z.string(),
21
+
22
+ // ─── R2 bindings ───
23
+ r2Binding: z.string().default('MEDIA_BUCKET'),
24
+
25
+ // ─── Admin import/upload helper route (optional) ───
26
+ admin: z
27
+ .object({
28
+ importRoute: z
29
+ .object({
30
+ enabled: z.boolean().default(false),
31
+ path: z.string().default('/api/video/import'),
32
+ requireAuth: z.boolean().default(true),
33
+ })
34
+ .default({}),
35
+ })
36
+ .default({}),
37
+
38
+ // ─── Premium gating (optional) ───
39
+ premium: z
40
+ .object({
41
+ enabled: z.boolean().default(false),
42
+ sessionRoute: z.string().default('/api/video/session'),
43
+ playbackRoute: z.string().default('/api/video/playback'),
44
+ sessionCookieName: z
45
+ .string()
46
+ .regex(/^[a-zA-Z0-9_-]+$/, 'cookie name must be alphanumeric with underscores or hyphens')
47
+ .default('gl_video_session'),
48
+ sessionScope: z.enum(['per-video', 'wildcard']).default('per-video'),
49
+ sessionTtl: z
50
+ .string()
51
+ .default('1h')
52
+ .transform((value, ctx) => {
53
+ const seconds = parseDurationToSeconds(value)
54
+ if (seconds === null) {
55
+ ctx.addIssue({
56
+ code: z.ZodIssueCode.custom,
57
+ message: `invalid sessionTtl "${value}". Expected a duration string like "1h", "30m", or "2h30m".`,
58
+ })
59
+ return z.NEVER
60
+ }
61
+ if (seconds < SESSION_TTL_MIN_SECONDS) {
62
+ ctx.addIssue({
63
+ code: z.ZodIssueCode.custom,
64
+ message: `sessionTtl "${value}" resolves to ${seconds}s; minimum is ${SESSION_TTL_MIN_SECONDS}s.`,
65
+ })
66
+ return z.NEVER
67
+ }
68
+ if (seconds > SESSION_TTL_MAX_SECONDS) {
69
+ ctx.addIssue({
70
+ code: z.ZodIssueCode.custom,
71
+ message: `sessionTtl "${value}" resolves to ${seconds}s; maximum is ${SESSION_TTL_MAX_SECONDS}s (24h).`,
72
+ })
73
+ return z.NEVER
74
+ }
75
+ return seconds
76
+ }),
77
+ signingSecretEnv: z.string().default('GL_VIDEO_SESSION_SECRET'),
78
+ accessCheckModule: z
79
+ .object({
80
+ module: z.string().min(1),
81
+ export: z.string().min(1).default('accessCheck'),
82
+ })
83
+ .optional(),
84
+ })
85
+ .optional(),
86
+
87
+ // ─── Player defaults ───
88
+ player: z
89
+ .object({
90
+ autoplay: z.boolean().default(false),
91
+ muted: z.boolean().default(false),
92
+ preload: z.enum(['none', 'metadata', 'auto']).default('metadata'),
93
+ showCaptions: z.boolean().default(true),
94
+ showChapters: z.boolean().default(true),
95
+ controls: z.boolean().default(true),
96
+ startingQuality: z.enum(['auto', 'highest', 'lowest']).default('auto'),
97
+ })
98
+ .default({}),
99
+
100
+ // ─── YouTube embed fallback ───
101
+ youtube: z
102
+ .object({
103
+ enabled: z.boolean().default(true),
104
+ privacyMode: z.boolean().default(true),
105
+ lazyLoad: z.boolean().default(true),
106
+ })
107
+ .default({}),
108
+
109
+ // ─── Analytics ───
110
+ analytics: z
111
+ .object({
112
+ enabled: z.boolean().default(true),
113
+ progressMarks: z.array(z.number().min(0).max(1)).default([0.25, 0.5, 0.75, 0.95]),
114
+ })
115
+ .default({}),
116
+ })
117
+
118
+ export type VideoOptions = z.input<typeof videoOptionsSchema>
119
+ export type ResolvedVideoOptions = z.output<typeof videoOptionsSchema>
@@ -0,0 +1,92 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
2
+ import { config } from 'virtual:growth-labs/video/config'
3
+ import type { APIRoute } from 'astro'
4
+ import { type HlsBundleFile, ingestHlsBundle, type VideoR2Bucket } from '../utils/ingest.js'
5
+
6
+ function json(body: unknown, status: number): Response {
7
+ return new Response(JSON.stringify(body), {
8
+ status,
9
+ headers: { 'Content-Type': 'application/json' },
10
+ })
11
+ }
12
+
13
+ function isFile(value: FormDataEntryValue | null): value is File {
14
+ return typeof File !== 'undefined' && value instanceof File
15
+ }
16
+
17
+ async function fileToHlsAsset(file: File, path: string): Promise<HlsBundleFile> {
18
+ const normalizedPath = path || file.name
19
+ const body = normalizedPath.endsWith('.m3u8') ? await file.text() : file
20
+ return {
21
+ path: normalizedPath,
22
+ body,
23
+ contentType: file.type || undefined,
24
+ }
25
+ }
26
+
27
+ function isAuthenticated(locals: unknown): boolean {
28
+ const user = (locals as { user?: { authenticated?: boolean } }).user
29
+ return user?.authenticated === true
30
+ }
31
+
32
+ export const POST: APIRoute = async ({ request, locals }) => {
33
+ const routeOptions = config.admin.importRoute
34
+ if (routeOptions.requireAuth && !isAuthenticated(locals)) {
35
+ return json({ error: 'Unauthorized' }, 401)
36
+ }
37
+
38
+ const env = cloudflareEnv as Record<string, unknown>
39
+ const bucket = env[config.r2Binding] as VideoR2Bucket | undefined
40
+ if (!bucket) {
41
+ return json({ error: `R2 bucket "${config.r2Binding}" not configured` }, 500)
42
+ }
43
+
44
+ let formData: FormData
45
+ try {
46
+ formData = await request.formData()
47
+ } catch {
48
+ return json({ error: 'Expected multipart form data' }, 400)
49
+ }
50
+
51
+ const videoId = formData.get('videoId')
52
+ if (typeof videoId !== 'string' || videoId.trim().length === 0) {
53
+ return json({ error: 'Missing videoId' }, 400)
54
+ }
55
+
56
+ const files = formData.getAll('files').filter(isFile)
57
+ if (files.length === 0) {
58
+ return json({ error: 'No HLS files provided' }, 400)
59
+ }
60
+
61
+ const paths = formData.getAll('paths').map((value) => String(value))
62
+ if (paths.length > 0 && paths.length !== files.length) {
63
+ return json({ error: 'paths must match files length' }, 400)
64
+ }
65
+
66
+ const hlsFiles = await Promise.all(
67
+ files.map((file, index) => fileToHlsAsset(file, paths[index] ?? file.name)),
68
+ )
69
+
70
+ const posterValue = formData.get('poster')
71
+ const poster = isFile(posterValue)
72
+ ? {
73
+ path: posterValue.name || 'poster.jpg',
74
+ body: posterValue as File,
75
+ contentType: posterValue.type || undefined,
76
+ }
77
+ : undefined
78
+
79
+ try {
80
+ const result = await ingestHlsBundle({
81
+ bucket,
82
+ publicDomain: config.publicDomain,
83
+ videoId,
84
+ files: hlsFiles,
85
+ poster,
86
+ })
87
+ return json(result, 200)
88
+ } catch (error) {
89
+ const message = error instanceof Error ? error.message : String(error)
90
+ return json({ error: message }, 400)
91
+ }
92
+ }
@@ -0,0 +1,101 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
2
+ import { config } from 'virtual:growth-labs/video/config'
3
+ import type { APIRoute } from 'astro'
4
+ import { verifySession } from '../utils/session.js'
5
+
6
+ interface R2LikeBucket {
7
+ get(
8
+ key: string,
9
+ opts?: { range?: { offset?: number; length?: number; suffix?: number } },
10
+ ): Promise<R2LikeObject | null>
11
+ }
12
+
13
+ interface R2LikeObject {
14
+ body: ReadableStream
15
+ httpEtag: string
16
+ writeHttpMetadata(headers: Headers): void
17
+ }
18
+
19
+ function inferContentType(key: string): string {
20
+ if (key.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl'
21
+ if (key.endsWith('.m4s')) return 'video/iso.segment'
22
+ if (key.endsWith('.mp4')) return 'video/mp4'
23
+ if (key.endsWith('.vtt')) return 'text/vtt'
24
+ return 'application/octet-stream'
25
+ }
26
+
27
+ function parseRangeHeader(
28
+ header: string,
29
+ ): { offset?: number; length?: number; suffix?: number } | undefined {
30
+ const m = header.match(/^bytes=(\d*)-(\d*)$/)
31
+ if (!m) return undefined
32
+ const start = m[1]
33
+ const end = m[2]
34
+ if (start === '' && end !== '') return { suffix: parseInt(end, 10) }
35
+ if (start !== '' && end === '') return { offset: parseInt(start, 10) }
36
+ if (start !== '' && end !== '') {
37
+ const s = parseInt(start, 10)
38
+ const e = parseInt(end, 10)
39
+ return { offset: s, length: e - s + 1 }
40
+ }
41
+ return undefined
42
+ }
43
+
44
+ function readCookie(header: string | null, name: string): string | null {
45
+ if (!header) return null
46
+ const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`))
47
+ return match ? match[1] : null
48
+ }
49
+
50
+ export const GET: APIRoute = async (context) => {
51
+ const { request, params } = context
52
+ const env = cloudflareEnv as Record<string, unknown>
53
+
54
+ const rest = params.path as string | undefined
55
+ if (!rest || rest.length === 0) {
56
+ return new Response('invalid path', { status: 400 })
57
+ }
58
+
59
+ const segments = rest.split('/').filter(Boolean)
60
+ if (segments.length < 2) {
61
+ return new Response('invalid path', { status: 400 })
62
+ }
63
+ if (segments.some((s) => s === '..' || s === '.')) {
64
+ return new Response('invalid path', { status: 400 })
65
+ }
66
+
67
+ if (!config.premium) return new Response('premium disabled', { status: 404 })
68
+
69
+ const videoId = segments[0]
70
+ const subKey = segments.slice(1).join('/')
71
+
72
+ const cookie = readCookie(request.headers.get('cookie'), config.premium.sessionCookieName)
73
+ if (!cookie) return new Response('unauthorized', { status: 401 })
74
+
75
+ const secret = env[config.premium.signingSecretEnv] as string | undefined
76
+ if (!secret) return new Response('misconfigured', { status: 500 })
77
+
78
+ const session = await verifySession(cookie, secret, videoId)
79
+ if (!session) return new Response('forbidden', { status: 403 })
80
+
81
+ const bucket = env[config.r2Binding] as R2LikeBucket | undefined
82
+ if (!bucket) return new Response('misconfigured', { status: 500 })
83
+
84
+ const rangeHeader = request.headers.get('range')
85
+ const range = rangeHeader ? parseRangeHeader(rangeHeader) : undefined
86
+
87
+ const r2Key = `video/${videoId}/${subKey}`
88
+ const obj = await bucket.get(r2Key, range ? { range } : undefined)
89
+ if (!obj) return new Response('not found', { status: 404 })
90
+
91
+ const headers = new Headers()
92
+ obj.writeHttpMetadata(headers)
93
+ headers.set('etag', obj.httpEtag)
94
+ headers.set('content-type', inferContentType(r2Key))
95
+ headers.set('cache-control', r2Key.endsWith('.m3u8') ? 'no-cache' : 'public, max-age=3600')
96
+
97
+ return new Response(obj.body, {
98
+ status: range ? 206 : 200,
99
+ headers,
100
+ })
101
+ }
@@ -0,0 +1,85 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
2
+ import { accessRegistry } from 'virtual:growth-labs/video/access-check'
3
+ import { config } from 'virtual:growth-labs/video/config'
4
+ import type { APIRoute } from 'astro'
5
+ import { issueSession } from '../utils/session.js'
6
+
7
+ const MIN_SECRET_BYTES = 32
8
+
9
+ function json(body: unknown, status: number): Response {
10
+ return new Response(JSON.stringify(body), {
11
+ status,
12
+ headers: { 'Content-Type': 'application/json' },
13
+ })
14
+ }
15
+
16
+ export const POST: APIRoute = async (context) => {
17
+ const { request } = context
18
+ const env = cloudflareEnv as Record<string, unknown>
19
+
20
+ let raw: unknown
21
+ try {
22
+ raw = await request.json()
23
+ } catch {
24
+ return json({ error: 'invalid_body' }, 400)
25
+ }
26
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
27
+ return json({ error: 'invalid_body' }, 400)
28
+ }
29
+ const body = raw as { videoId?: unknown }
30
+ if (typeof body.videoId !== 'string' || body.videoId.length === 0) {
31
+ return json({ error: 'missing_video_id' }, 400)
32
+ }
33
+
34
+ if (!config.premium) {
35
+ return json({ error: 'premium_not_enabled' }, 500)
36
+ }
37
+
38
+ const accessCheck = accessRegistry.check
39
+ if (typeof accessCheck !== 'function') {
40
+ return json({ error: 'access_check_not_configured' }, 500)
41
+ }
42
+
43
+ const result = await accessCheck({ videoId: body.videoId, request, env }, context)
44
+ if (!result.allowed) {
45
+ return json({ error: 'forbidden', reason: result.reason }, 403)
46
+ }
47
+
48
+ const secret = env[config.premium.signingSecretEnv]
49
+ if (typeof secret !== 'string' || secret.length === 0) {
50
+ return json({ error: 'signing_secret_not_configured' }, 500)
51
+ }
52
+ if (secret.length < MIN_SECRET_BYTES) {
53
+ return json({ error: 'signing_secret_too_short' }, 500)
54
+ }
55
+
56
+ const ttlSeconds = config.premium.sessionTtl
57
+ const videoIds = config.premium.sessionScope === 'wildcard' ? '*' : [body.videoId]
58
+ const now = Math.floor(Date.now() / 1000)
59
+ const cookieValue = await issueSession(
60
+ {
61
+ videoIds,
62
+ subjectId: result.subjectId ?? 'anonymous',
63
+ issuedAt: now,
64
+ expiresAt: now + ttlSeconds,
65
+ },
66
+ secret,
67
+ )
68
+
69
+ const cookieParts = [
70
+ `${config.premium.sessionCookieName}=${cookieValue}`,
71
+ `Max-Age=${ttlSeconds}`,
72
+ 'Path=/',
73
+ 'HttpOnly',
74
+ 'Secure',
75
+ 'SameSite=Lax',
76
+ ]
77
+
78
+ return new Response(JSON.stringify({ ok: true }), {
79
+ status: 200,
80
+ headers: {
81
+ 'Content-Type': 'application/json',
82
+ 'Set-Cookie': cookieParts.join('; '),
83
+ },
84
+ })
85
+ }
package/src/types.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { APIContext } from 'astro'
2
+
3
+ export interface AccessCheckParams {
4
+ videoId: string
5
+ request: Request
6
+ env: Record<string, unknown>
7
+ }
8
+
9
+ export interface AccessResult {
10
+ allowed: boolean
11
+ reason?: string
12
+ subjectId?: string
13
+ }
14
+
15
+ export type AccessCheck = (params: AccessCheckParams, context: APIContext) => Promise<AccessResult>
16
+
17
+ export interface Chapter {
18
+ index: number
19
+ title: string
20
+ startSeconds: number
21
+ endSeconds: number
22
+ }
23
+
24
+ export interface ChaptersFile {
25
+ chapters: Chapter[]
26
+ }
27
+
28
+ export interface VttCue {
29
+ startSeconds: number
30
+ endSeconds: number
31
+ text: string
32
+ }
33
+
34
+ export interface VideoObjectInput {
35
+ videoId: string
36
+ title: string
37
+ description: string
38
+ publicDomain: string
39
+ uploadDate: string
40
+ durationSeconds: number
41
+ contentUrl?: string
42
+ embedUrl: string
43
+ thumbnailUrls?: string[]
44
+ transcript?: string
45
+ chapters?: Array<{ title: string; startSeconds: number }>
46
+ }
@@ -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
+ }