@kyyinfinite/lumina 1.0.0

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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +629 -0
  3. package/examples/ai-rich.js +84 -0
  4. package/examples/button.js +57 -0
  5. package/examples/carousel.js +51 -0
  6. package/examples/interactive.js +102 -0
  7. package/examples/media.js +66 -0
  8. package/examples/simple-bot.js +56 -0
  9. package/package.json +86 -0
  10. package/src/builders/ai-rich.js +644 -0
  11. package/src/builders/base.js +109 -0
  12. package/src/builders/button-v2.js +159 -0
  13. package/src/builders/button.js +398 -0
  14. package/src/builders/card.js +168 -0
  15. package/src/builders/carousel.js +122 -0
  16. package/src/builders/index.d.ts +1 -0
  17. package/src/builders/index.js +13 -0
  18. package/src/client/bot.js +192 -0
  19. package/src/client/connection.js +180 -0
  20. package/src/errors.js +88 -0
  21. package/src/index.d.ts +458 -0
  22. package/src/index.js +152 -0
  23. package/src/media/fetch.js +67 -0
  24. package/src/media/image.js +86 -0
  25. package/src/media/index.d.ts +1 -0
  26. package/src/media/index.js +12 -0
  27. package/src/media/resolver.js +115 -0
  28. package/src/media/uploader.js +65 -0
  29. package/src/media/video.js +195 -0
  30. package/src/parsers/code-tokenizer-keywords.js +128 -0
  31. package/src/parsers/code-tokenizer.js +191 -0
  32. package/src/parsers/index.d.ts +1 -0
  33. package/src/parsers/index.js +11 -0
  34. package/src/parsers/inline-entity.js +231 -0
  35. package/src/parsers/table-metadata.js +69 -0
  36. package/src/proto/enums.js +170 -0
  37. package/src/proto/index.d.ts +1 -0
  38. package/src/proto/index.js +13 -0
  39. package/src/proto/layouts.js +89 -0
  40. package/src/proto/primitives.js +181 -0
  41. package/src/proto/relay-nodes.js +55 -0
  42. package/src/proto/rich-response.js +144 -0
  43. package/src/proto/updater.js +318 -0
  44. package/src/services/index.d.ts +1 -0
  45. package/src/services/index.js +10 -0
  46. package/src/services/media-service.js +184 -0
  47. package/src/services/message-service.js +288 -0
  48. package/src/services/proto-service.js +90 -0
  49. package/src/utils/id.js +42 -0
  50. package/src/utils/logger.js +65 -0
  51. package/src/utils/mime.js +104 -0
  52. package/src/utils/promise.js +52 -0
  53. package/src/utils/validator.js +129 -0
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @file fetch.js
3
+ * @module lumina/media/fetch
4
+ *
5
+ * HTTP fetcher for media URLs. Returns a Buffer. Honours a configurable
6
+ * timeout and an opt-in `silent` mode (default OFF — fail loud).
7
+ */
8
+
9
+ import { MediaError } from '../errors.js'
10
+
11
+ /**
12
+ * @typedef {object} FetchOptions
13
+ * @property {boolean} [silent=false] Swallow HTTP errors and return Buffer.alloc(0).
14
+ * @property {number} [timeout=30000] Request timeout in milliseconds.
15
+ * @property {Record<string, string>} [headers] Additional request headers.
16
+ * @property {AbortSignal} [signal] External abort signal.
17
+ */
18
+
19
+ /**
20
+ * Fetch a URL and return its body as a Buffer.
21
+ *
22
+ * @param {string} url
23
+ * @param {FetchOptions} [opts]
24
+ * @returns {Promise<Buffer>}
25
+ * @throws {MediaError} When `silent` is false (default) and the request fails.
26
+ */
27
+ export async function fetchBuffer(url, opts = {}) {
28
+ const { silent = false, timeout = 30_000, headers, signal } = opts
29
+
30
+ try {
31
+ if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
32
+ throw new MediaError(`invalid URL: ${url}`, { code: 'LUMINA_MEDIA_BAD_URL' })
33
+ }
34
+
35
+ const controller = new AbortController()
36
+ const timer = setTimeout(() => controller.abort(), timeout)
37
+ // Combine external signal with our timeout signal.
38
+ if (signal) {
39
+ if (signal.aborted) controller.abort()
40
+ else signal.addEventListener('abort', () => controller.abort(), { once: true })
41
+ }
42
+
43
+ const response = await fetch(url, {
44
+ headers,
45
+ signal: controller.signal,
46
+ redirect: 'follow',
47
+ })
48
+ clearTimeout(timer)
49
+
50
+ if (!response.ok) {
51
+ throw new MediaError(`HTTP ${response.status} for ${url}`, {
52
+ code: 'LUMINA_MEDIA_HTTP_ERROR',
53
+ })
54
+ }
55
+
56
+ return Buffer.from(await response.arrayBuffer())
57
+ } catch (err) {
58
+ if (silent) return Buffer.alloc(0)
59
+ if (err instanceof MediaError) throw err
60
+ throw new MediaError(`fetch failed for ${url}: ${err.message}`, {
61
+ code: 'LUMINA_MEDIA_FETCH_FAILED',
62
+ cause: err,
63
+ })
64
+ }
65
+ }
66
+
67
+ export default fetchBuffer
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @file image.js
3
+ * @module lumina/media/image
4
+ *
5
+ * Image resizing via `sharp`. Sharp is an optional peer dependency — the
6
+ * module lazy-imports it on first use so that users who never touch image
7
+ * resizing do not need it installed.
8
+ */
9
+
10
+ import { MediaError } from '../errors.js'
11
+
12
+ let sharpPromise = null
13
+
14
+ /**
15
+ * Lazy-load sharp. Cached so the import cost is paid only once.
16
+ *
17
+ * @returns {Promise<typeof import('sharp')>}
18
+ */
19
+ async function loadSharp() {
20
+ if (!sharpPromise) {
21
+ sharpPromise = (async () => {
22
+ try {
23
+ return await import('sharp')
24
+ } catch (err) {
25
+ throw new MediaError(
26
+ "sharp is not installed. Install it with `npm i sharp` to use image resizing.",
27
+ { code: 'LUMINA_MEDIA_SHARP_MISSING', cause: err },
28
+ )
29
+ }
30
+ })()
31
+ }
32
+ return sharpPromise
33
+ }
34
+
35
+ /**
36
+ * @typedef {object} ResizeOptions
37
+ * @property {number} [width]
38
+ * @property {number} [height]
39
+ * @property {'cover'|'contain'|'inside'|'outside'} [fit='cover']
40
+ * @property {'png'|'jpeg'|'webp'} [format='png']
41
+ * @property {object} [background] Background colour for transparent areas.
42
+ */
43
+
44
+ /**
45
+ * Resize a buffer containing an image. Returns a Buffer in the requested format.
46
+ *
47
+ * @param {Buffer} buffer
48
+ * @param {ResizeOptions} [opts]
49
+ * @returns {Promise<Buffer>}
50
+ */
51
+ export async function resize(buffer, opts = {}) {
52
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
53
+ throw new MediaError('resize() requires a non-empty Buffer', {
54
+ code: 'LUMINA_MEDIA_EMPTY_BUFFER',
55
+ })
56
+ }
57
+
58
+ const { width = 300, height = 300, fit = 'cover', format = 'png', background } = opts
59
+ const sharp = await loadSharp()
60
+
61
+ let pipeline = sharp(buffer).resize(width, height, {
62
+ fit,
63
+ position: 'center',
64
+ background: background ?? { r: 0, g: 0, b: 0, alpha: 0 },
65
+ })
66
+
67
+ if (format === 'jpeg') pipeline = pipeline.jpeg()
68
+ else if (format === 'webp') pipeline = pipeline.webp()
69
+ else pipeline = pipeline.png()
70
+
71
+ return pipeline.toBuffer()
72
+ }
73
+
74
+ /**
75
+ * Convenience: resize to a square thumbnail (default 300×300 PNG cover).
76
+ *
77
+ * @param {Buffer} buffer
78
+ * @param {number} [size=300]
79
+ * @param {ResizeOptions['format']} [format]
80
+ * @returns {Promise<Buffer>}
81
+ */
82
+ export async function thumbnail(buffer, size = 300, format = 'png') {
83
+ return resize(buffer, { width: size, height: size, fit: 'cover', format })
84
+ }
85
+
86
+ export default { resize, thumbnail }
@@ -0,0 +1 @@
1
+ export * from '../index.d.ts'
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @file media/index.js
3
+ * @module lumina/media
4
+ *
5
+ * Barrel re-export for the media layer.
6
+ */
7
+
8
+ export { fetchBuffer } from './fetch.js'
9
+ export { resize, thumbnail } from './image.js'
10
+ export { getMp4Duration, extractThumbnail } from './video.js'
11
+ export { uploadToWhatsApp, DEFAULT_UPLOAD_JID } from './uploader.js'
12
+ export { resolveMedia } from './resolver.js'
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @file resolver.js
3
+ * @module lumina/media/resolver
4
+ *
5
+ * Unified media resolver. Accepts URL | base64 string | Buffer | array-of-any
6
+ * and returns a normalised representation per the requested strategy.
7
+ *
8
+ * Bug fix vs legacy: the original `Toolkit.resolveMedia` had two near-identical
9
+ * branches (WAUrl vs non-WAUrl) that both called `fetchBuffer` — collapsed
10
+ * into a single, explicit 3-strategy switch (`auto | url-only | buffer |
11
+ * base64 | upload`).
12
+ */
13
+
14
+ import { MediaError } from '../errors.js'
15
+ import { fetchBuffer } from './fetch.js'
16
+ import { resize } from './image.js'
17
+ import { uploadToWhatsApp } from './uploader.js'
18
+
19
+ const HTTP_RE = /^https?:\/\/.+/i
20
+ const WA_HOST_RE = /^https?:\/\/[^/]*\.whatsapp\.net\//i
21
+
22
+ /** @typedef {'auto'|'url-only'|'buffer'|'base64'|'upload'} ResolveStrategy */
23
+
24
+ /**
25
+ * @typedef {object} ResolveOptions
26
+ * @property {'image'|'video'|'audio'|'document'} [mediaType='image']
27
+ * @property {ResolveStrategy} [strategy='auto']
28
+ * @property {{ width: number, height: number, fit?: string }} [resize] Resize before returning (buffer/base64 only).
29
+ * @property {boolean} [silent=false]
30
+ * @property {string} [jid] Override upload JID.
31
+ */
32
+
33
+ /**
34
+ * Resolve a single media source.
35
+ *
36
+ * @param {import('../client/connection.js').Connection} conn
37
+ * @param {string | Buffer} source
38
+ * @param {ResolveOptions} [opts]
39
+ * @returns {Promise<string | Buffer>}
40
+ */
41
+ export async function resolveMedia(conn, source, opts = {}) {
42
+ const {
43
+ mediaType = 'image',
44
+ strategy = 'auto',
45
+ resize: resizeOpts,
46
+ silent = false,
47
+ jid,
48
+ } = opts
49
+
50
+ // Array support: recurse with identical opts.
51
+ if (Array.isArray(source)) {
52
+ return Promise.all(source.map((s) => resolveMedia(conn, s, opts)))
53
+ }
54
+
55
+ const originalIsBuffer = Buffer.isBuffer(source)
56
+
57
+ // Stage 1: normalise to either { url } or { buffer }.
58
+ let buffer = null
59
+ let url = null
60
+
61
+ if (typeof source === 'string') {
62
+ if (HTTP_RE.test(source)) {
63
+ const isWaUrl = WA_HOST_RE.test(source)
64
+ if (strategy === 'url-only' && isWaUrl) return source
65
+ // For HTTP URLs we always need the bytes — either because the user
66
+ // asked for buffer/base64, or because we need to upload to WhatsApp.
67
+ buffer = await fetchBuffer(source, { silent: true })
68
+ url = source
69
+ } else {
70
+ // Treat as base64.
71
+ buffer = Buffer.from(source, 'base64')
72
+ }
73
+ } else if (originalIsBuffer) {
74
+ buffer = source
75
+ } else {
76
+ throw new MediaError('source must be string URL, base64 string, or Buffer', {
77
+ code: 'LUMINA_MEDIA_BAD_SOURCE',
78
+ })
79
+ }
80
+
81
+ if (!buffer || buffer.length === 0) {
82
+ if (silent) return strategy === 'base64' ? '' : Buffer.alloc(0)
83
+ throw new MediaError(`could not fetch media for source: ${String(source).slice(0, 80)}`, {
84
+ code: 'LUMINA_MEDIA_EMPTY_AFTER_FETCH',
85
+ })
86
+ }
87
+
88
+ // Stage 2: optional resize.
89
+ if (resizeOpts && buffer) {
90
+ buffer = await resize(buffer, resizeOpts)
91
+ }
92
+
93
+ // Stage 3: format per strategy.
94
+ switch (strategy) {
95
+ case 'buffer':
96
+ return buffer
97
+ case 'base64':
98
+ return buffer.toString('base64')
99
+ case 'url-only':
100
+ if (url && WA_HOST_RE.test(url)) return url
101
+ throw new MediaError('url-only strategy requires a wa.me URL', {
102
+ code: 'LUMINA_MEDIA_URL_ONLY_FAILED',
103
+ })
104
+ case 'upload':
105
+ return uploadToWhatsApp(conn, buffer, mediaType, { jid })
106
+ case 'auto':
107
+ default:
108
+ if (url && WA_HOST_RE.test(url)) return url
109
+ if (originalIsBuffer) return uploadToWhatsApp(conn, buffer, mediaType, { jid })
110
+ // We have a non-WA URL — upload to WhatsApp so the consumer gets a CDN URL.
111
+ return uploadToWhatsApp(conn, buffer, mediaType, { jid })
112
+ }
113
+ }
114
+
115
+ export default resolveMedia
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @file uploader.js
3
+ * @module lumina/media/uploader
4
+ *
5
+ * Thin wrapper around Baileys' `prepareWAMessageMedia` that uploads a buffer
6
+ * (or `{ url }`) to WhatsApp and returns the resulting CDN URL.
7
+ *
8
+ * Bug fix vs legacy: the hardcoded `jid: '@newsletter'` (which silently
9
+ * forced every media upload through the newsletter queue) is replaced by a
10
+ * configurable default that the user can override via the `Connection`
11
+ * options or per-call `jid`.
12
+ */
13
+
14
+ import { MediaError } from '../errors.js'
15
+
16
+ /**
17
+ * Default pre-upload JID. Use a stable bot JID that WhatsApp accepts for
18
+ * media pre-uploads — historically the legacy code used `@newsletter`, but
19
+ * that route is rate-limited and intended for broadcast use. The default
20
+ * `s.whatsapp.net` route is more permissive and works for bot accounts.
21
+ */
22
+ export const DEFAULT_UPLOAD_JID = '62831@s.whatsapp.net'
23
+
24
+ /**
25
+ * Upload a media source to WhatsApp and return its CDN URL.
26
+ *
27
+ * @param {import('../client/connection.js').Connection} conn
28
+ * @param {Buffer | { url: string }} source
29
+ * @param {'image'|'video'|'audio'|'document'} mediaType
30
+ * @param {object} [opts]
31
+ * @param {string} [opts.jid=DEFAULT_UPLOAD_JID] Pre-upload JID.
32
+ * @param {object} [opts.options] Extra options forwarded to `prepareWAMessageMedia`.
33
+ * @returns {Promise<string>} CDN URL.
34
+ */
35
+ export async function uploadToWhatsApp(conn, source, mediaType, opts = {}) {
36
+ if (!conn?.uploadMedia) {
37
+ throw new MediaError('connection.uploadMedia is not available', {
38
+ code: 'LUMINA_MEDIA_NO_UPLOAD_PORT',
39
+ })
40
+ }
41
+
42
+ const jid = opts.jid ?? DEFAULT_UPLOAD_JID
43
+ const media = {
44
+ [mediaType]: Buffer.isBuffer(source) ? source : { url: source.url ?? source },
45
+ }
46
+
47
+ try {
48
+ const result = await conn.uploadMedia(media, { jid, ...(opts.options ?? {}) })
49
+ const url = result?.[mediaType]?.url
50
+ if (!url) {
51
+ throw new MediaError(`upload succeeded but no URL returned for mediaType=${mediaType}`, {
52
+ code: 'LUMINA_MEDIA_UPLOAD_NO_URL',
53
+ })
54
+ }
55
+ return url
56
+ } catch (err) {
57
+ if (err instanceof MediaError) throw err
58
+ throw new MediaError(`upload failed: ${err.message}`, {
59
+ code: 'LUMINA_MEDIA_UPLOAD_FAILED',
60
+ cause: err,
61
+ })
62
+ }
63
+ }
64
+
65
+ export default { uploadToWhatsApp, DEFAULT_UPLOAD_JID }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * @file video.js
3
+ * @module lumina/media/video
4
+ *
5
+ * Pure-Node MP4 box parser (for duration) + ffmpeg-based thumbnail extractor.
6
+ *
7
+ * The legacy `Toolkit.getMp4Duration` was a hand-rolled ISO-BMFF box walker
8
+ * that worked but had no tests and several silent-failure paths. Lumina
9
+ * keeps the same approach (no native deps for duration) but factors it into
10
+ * a single, documented function.
11
+ *
12
+ * `extractThumbnail` lazy-imports `fluent-ffmpeg` — users who never send
13
+ * video thumbnails do not need it installed.
14
+ */
15
+
16
+ import { PassThrough, Readable } from 'node:stream'
17
+
18
+ import { MediaError } from '../errors.js'
19
+ import { resize } from './image.js'
20
+
21
+ let ffmpegPromise = null
22
+
23
+ async function loadFfmpeg() {
24
+ if (!ffmpegPromise) {
25
+ ffmpegPromise = (async () => {
26
+ try {
27
+ return (await import('fluent-ffmpeg')).default
28
+ } catch (err) {
29
+ throw new MediaError(
30
+ "fluent-ffmpeg is not installed. Install it with `npm i fluent-ffmpeg` (and ffmpeg itself) to use video thumbnails.",
31
+ { code: 'LUMINA_MEDIA_FFMPEG_MISSING', cause: err },
32
+ )
33
+ }
34
+ })()
35
+ }
36
+ return ffmpegPromise
37
+ }
38
+
39
+ /**
40
+ * Walk the ISO-BMFF box tree of an MP4 buffer and extract the duration
41
+ * (in seconds) from the first `mvhd` box found inside `moov`.
42
+ *
43
+ * Returns 0 when the buffer is invalid, truncated, or lacks a `moov`/`mvhd`
44
+ * box — `silent: true` (default) preserves the legacy swallow-on-failure
45
+ * behaviour so that video uploads do not hard-fail when metadata is missing.
46
+ *
47
+ * @param {Buffer} buffer
48
+ * @param {object} [opts]
49
+ * @param {boolean} [opts.silent=true]
50
+ * @returns {number} Duration in seconds (0 on failure).
51
+ */
52
+ export function getMp4Duration(buffer, opts = {}) {
53
+ const { silent = true } = opts
54
+
55
+ const fail = (msg) => {
56
+ if (silent) return 0
57
+ throw new MediaError(msg, { code: 'LUMINA_MEDIA_MP4_PARSE_FAILED' })
58
+ }
59
+
60
+ try {
61
+ if (!Buffer.isBuffer(buffer) || buffer.length < 8) {
62
+ return fail('invalid buffer: too short')
63
+ }
64
+
65
+ let offset = 0
66
+ while (offset < buffer.length - 8) {
67
+ const size = buffer.readUInt32BE(offset)
68
+ if (size < 8 || offset + size > buffer.length) {
69
+ return fail('invalid atom size')
70
+ }
71
+
72
+ const type = buffer.toString('ascii', offset + 4, offset + 8)
73
+ if (type === 'moov') {
74
+ let moovOffset = offset + 8
75
+ const moovEnd = offset + size
76
+ while (moovOffset < moovEnd - 8) {
77
+ const childSize = buffer.readUInt32BE(moovOffset)
78
+ if (childSize < 8 || moovOffset + childSize > moovEnd) {
79
+ return fail('invalid child atom size')
80
+ }
81
+ const childType = buffer.toString('ascii', moovOffset + 4, moovOffset + 8)
82
+ if (childType === 'mvhd') {
83
+ const version = buffer.readUInt8(moovOffset + 8)
84
+ if (version === 0) {
85
+ const timescale = buffer.readUInt32BE(moovOffset + 20)
86
+ const duration = buffer.readUInt32BE(moovOffset + 24)
87
+ if (!timescale) return fail('invalid timescale')
88
+ return duration / timescale
89
+ }
90
+ if (version === 1) {
91
+ const timescale = buffer.readUInt32BE(moovOffset + 32)
92
+ const duration = Number(buffer.readBigUInt64BE(moovOffset + 36))
93
+ if (!timescale) return fail('invalid timescale')
94
+ return duration / timescale
95
+ }
96
+ }
97
+ moovOffset += childSize
98
+ }
99
+ }
100
+ offset += size
101
+ }
102
+ return fail('no mvhd found')
103
+ } catch (err) {
104
+ if (silent) return 0
105
+ throw new MediaError(`mp4 duration parse error: ${err.message}`, {
106
+ code: 'LUMINA_MEDIA_MP4_PARSE_FAILED',
107
+ cause: err,
108
+ })
109
+ }
110
+ }
111
+
112
+ /**
113
+ * @typedef {object} ThumbnailOptions
114
+ * @property {number|string} [time='auto'] Seek position (seconds) or 'auto' (= 20% of duration, capped at 10s).
115
+ * @property {number} [width=300]
116
+ * @property {number} [height=300]
117
+ * @property {'png'|'jpeg'|'webp'} [format='png']
118
+ * @property {boolean} [resizeOutput=true] Pass the frame through `sharp.resize()`.
119
+ * @property {boolean} [silent=true] Swallow ffmpeg errors and return empty Buffer.
120
+ * @property {'buffer'|'base64'} [result='buffer']
121
+ */
122
+
123
+ /**
124
+ * Extract a single frame from an MP4 buffer at the given timestamp.
125
+ *
126
+ * @param {Buffer} videoBuffer
127
+ * @param {ThumbnailOptions} [opts]
128
+ * @returns {Promise<Buffer|string>} Buffer or base64 string (per `result`).
129
+ */
130
+ export async function extractThumbnail(videoBuffer, opts = {}) {
131
+ const {
132
+ time,
133
+ width = 300,
134
+ height = 300,
135
+ format = 'png',
136
+ resizeOutput = true,
137
+ silent = true,
138
+ result = 'buffer',
139
+ } = opts
140
+
141
+ const ffmpeg = await loadFfmpeg()
142
+
143
+ return new Promise((resolve, reject) => {
144
+ const fail = (err) => {
145
+ if (silent) return resolve(result === 'base64' ? '' : Buffer.alloc(0))
146
+ reject(err instanceof MediaError ? err : new MediaError(err?.message ?? String(err), {
147
+ code: 'LUMINA_MEDIA_FFMPEG_FAILED',
148
+ cause: err,
149
+ }))
150
+ }
151
+
152
+ if (!Buffer.isBuffer(videoBuffer) || videoBuffer.length === 0) {
153
+ return fail(new MediaError('videoBuffer is empty', { code: 'LUMINA_MEDIA_EMPTY_BUFFER' }))
154
+ }
155
+
156
+ const seekTime = time ?? Math.min(getMp4Duration(videoBuffer) * 0.2, 10)
157
+
158
+ const input = new Readable({ read() {} })
159
+ input.push(videoBuffer)
160
+ input.push(null)
161
+
162
+ const output = new PassThrough()
163
+ const chunks = []
164
+ output.on('data', (c) => chunks.push(c))
165
+ output.on('end', async () => {
166
+ try {
167
+ let frame = Buffer.concat(chunks)
168
+ if (frame.length === 0) {
169
+ return fail(new MediaError('ffmpeg produced empty output', { code: 'LUMINA_MEDIA_FFMPEG_EMPTY' }))
170
+ }
171
+ if (resizeOutput) {
172
+ frame = await resize(frame, { width, height, fit: 'cover', format })
173
+ }
174
+ resolve(result === 'base64' ? frame.toString('base64') : frame)
175
+ } catch (err) {
176
+ fail(err)
177
+ }
178
+ })
179
+ output.on('error', fail)
180
+
181
+ try {
182
+ ffmpeg(input)
183
+ .outputOptions([`-ss ${seekTime}`, '-vframes 1', '-vcodec png', '-f image2pipe'])
184
+ .on('error', (err) => fail(new MediaError(`ffmpeg error: ${err.message}`, {
185
+ code: 'LUMINA_MEDIA_FFMPEG_FAILED',
186
+ cause: err,
187
+ })))
188
+ .pipe(output, { end: true })
189
+ } catch (err) {
190
+ fail(err)
191
+ }
192
+ })
193
+ }
194
+
195
+ export default { getMp4Duration, extractThumbnail }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @file code-tokenizer-keywords.js
3
+ * @module lumina/parsers/code-tokenizer-keywords
4
+ *
5
+ * Language keyword catalog. Built ONCE at module-load time (vs the legacy
6
+ * `AIRich.tokenizer` which rebuilt a `new Set(...)` on every call).
7
+ *
8
+ * Twelve languages are supported out of the box. To add a new language,
9
+ * append a `Set` here — the lexer picks it up automatically.
10
+ */
11
+
12
+ /** @type {Record<string, Set<string>>} */
13
+ export const KEYWORDS = {
14
+ javascript: new Set([
15
+ 'break', 'case', 'catch', 'continue', 'debugger', 'default', 'delete', 'do', 'else',
16
+ 'export', 'extends', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof',
17
+ 'new', 'return', 'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void',
18
+ 'while', 'with', 'yield', 'class', 'const', 'let', 'static', 'async', 'await', 'get', 'set',
19
+ 'true', 'false', 'null', 'undefined',
20
+ ]),
21
+
22
+ typescript: new Set([
23
+ 'abstract', 'any', 'as', 'asserts', 'bigint', 'boolean', 'break', 'case', 'catch', 'class',
24
+ 'const', 'continue', 'debugger', 'declare', 'default', 'delete', 'do', 'else', 'enum',
25
+ 'export', 'extends', 'finally', 'for', 'from', 'function', 'if', 'implements', 'import',
26
+ 'in', 'infer', 'instanceof', 'interface', 'is', 'keyof', 'let', 'module', 'namespace',
27
+ 'new', 'never', 'null', 'number', 'object', 'of', 'override', 'package', 'private',
28
+ 'protected', 'public', 'readonly', 'require', 'return', 'satisfies', 'string', 'super',
29
+ 'switch', 'symbol', 'this', 'throw', 'true', 'false', 'try', 'type', 'typeof', 'undefined',
30
+ 'unknown', 'using', 'var', 'void', 'while', 'with', 'yield', 'async', 'await', 'static', 'as',
31
+ ]),
32
+
33
+ python: new Set([
34
+ 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class',
35
+ 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global',
36
+ 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return',
37
+ 'try', 'while', 'with', 'yield',
38
+ ]),
39
+
40
+ java: new Set([
41
+ 'abstract', 'assert', 'boolean', 'break', 'byte', 'case', 'catch', 'char', 'class', 'const',
42
+ 'continue', 'default', 'do', 'double', 'else', 'enum', 'extends', 'final', 'finally',
43
+ 'float', 'for', 'goto', 'if', 'implements', 'import', 'instanceof', 'int', 'interface',
44
+ 'long', 'native', 'new', 'package', 'private', 'protected', 'public', 'return', 'short',
45
+ 'static', 'strictfp', 'super', 'switch', 'synchronized', 'this', 'throw', 'throws',
46
+ 'transient', 'try', 'void', 'volatile', 'while', 'true', 'false', 'null',
47
+ ]),
48
+
49
+ golang: new Set([
50
+ 'break', 'case', 'chan', 'const', 'continue', 'default', 'defer', 'else', 'fallthrough',
51
+ 'for', 'func', 'go', 'goto', 'if', 'import', 'interface', 'map', 'package', 'range',
52
+ 'return', 'select', 'struct', 'switch', 'type', 'var', 'true', 'false', 'nil', 'iota',
53
+ ]),
54
+
55
+ c: new Set([
56
+ 'auto', 'break', 'case', 'char', 'const', 'continue', 'default', 'do', 'double', 'else',
57
+ 'enum', 'extern', 'float', 'for', 'goto', 'if', 'inline', 'int', 'long', 'register',
58
+ 'restrict', 'return', 'short', 'signed', 'sizeof', 'static', 'struct', 'switch', 'typedef',
59
+ 'union', 'unsigned', 'void', 'volatile', 'while', '_Bool', '_Complex', '_Imaginary',
60
+ ]),
61
+
62
+ cpp: new Set([
63
+ 'alignas', 'alignof', 'and', 'auto', 'bool', 'break', 'case', 'catch', 'char', 'class',
64
+ 'const', 'constexpr', 'continue', 'decltype', 'default', 'delete', 'do', 'double', 'else',
65
+ 'enum', 'explicit', 'export', 'extern', 'false', 'float', 'for', 'friend', 'goto', 'if',
66
+ 'inline', 'int', 'long', 'mutable', 'namespace', 'new', 'noexcept', 'nullptr', 'operator',
67
+ 'private', 'protected', 'public', 'register', 'return', 'short', 'signed', 'sizeof',
68
+ 'static', 'static_cast', 'struct', 'switch', 'template', 'this', 'throw', 'true', 'try',
69
+ 'typedef', 'typename', 'union', 'unsigned', 'using', 'virtual', 'void', 'volatile', 'while',
70
+ ]),
71
+
72
+ php: new Set([
73
+ 'abstract', 'and', 'array', 'as', 'break', 'callable', 'case', 'catch', 'class', 'clone',
74
+ 'const', 'continue', 'declare', 'default', 'do', 'echo', 'else', 'elseif', 'empty',
75
+ 'enddeclare', 'endfor', 'endforeach', 'endif', 'endswitch', 'endwhile', 'extends', 'final',
76
+ 'finally', 'fn', 'for', 'foreach', 'function', 'global', 'goto', 'if', 'implements',
77
+ 'include', 'include_once', 'instanceof', 'interface', 'match', 'namespace', 'new', 'null',
78
+ 'or', 'private', 'protected', 'public', 'readonly', 'require', 'require_once', 'return',
79
+ 'static', 'switch', 'throw', 'trait', 'try', 'use', 'var', 'while', 'yield', 'true',
80
+ 'false', 'fn',
81
+ ]),
82
+
83
+ rust: new Set([
84
+ 'as', 'async', 'await', 'break', 'const', 'continue', 'crate', 'dyn', 'else', 'enum',
85
+ 'extern', 'false', 'fn', 'for', 'if', 'impl', 'in', 'let', 'loop', 'match', 'mod', 'move',
86
+ 'mut', 'pub', 'ref', 'return', 'self', 'Self', 'static', 'struct', 'super', 'trait',
87
+ 'true', 'type', 'unsafe', 'use', 'where', 'while', 'yield',
88
+ ]),
89
+
90
+ html: new Set([
91
+ 'html', 'head', 'body', 'div', 'span', 'p', 'a', 'img', 'video', 'audio', 'script',
92
+ 'style', 'link', 'meta', 'form', 'input', 'button', 'table', 'tr', 'td', 'th', 'ul', 'ol',
93
+ 'li', 'section', 'article', 'header', 'footer', 'nav', 'main',
94
+ ]),
95
+
96
+ css: new Set([
97
+ 'color', 'background', 'background-color', 'background-image', 'background-size', 'border',
98
+ 'border-radius', 'margin', 'padding', 'width', 'height', 'display', 'position', 'top',
99
+ 'right', 'bottom', 'left', 'flex', 'flex-direction', 'justify-content', 'align-items',
100
+ 'grid', 'grid-template-columns', 'font', 'font-size', 'font-weight', 'text-align',
101
+ 'text-decoration', 'opacity', 'transition', 'transform', 'animation', 'z-index', 'overflow',
102
+ 'cursor', 'box-shadow', 'content', 'media', 'keyframes', 'important',
103
+ ]),
104
+
105
+ bash: new Set([
106
+ 'if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'esac',
107
+ 'function', 'in', 'select', 'until', 'break', 'continue', 'return', 'exit', 'export',
108
+ 'readonly', 'local', 'declare', 'unset', 'shift', 'eval', 'exec', 'trap', 'set', 'source',
109
+ 'alias', 'echo', 'printf', 'read', 'cd', 'pwd', 'pushd', 'popd',
110
+ ]),
111
+
112
+ markdown: new Set(['#', '##', '###', '####', '#####', '######']),
113
+ }
114
+
115
+ /** Languages where `//` introduces a line comment. */
116
+ export const SLASH_COMMENT_LANGS = new Set([
117
+ 'javascript', 'typescript', 'java', 'golang', 'c', 'cpp', 'php', 'rust', 'css',
118
+ ])
119
+
120
+ /** Languages where `#` introduces a line comment. */
121
+ export const HASH_COMMENT_LANGS = new Set(['python', 'bash', 'php', 'rust'])
122
+
123
+ /** Languages that support block comments with `/* ... *\/`. */
124
+ export const BLOCK_COMMENT_LANGS = new Set([
125
+ 'javascript', 'typescript', 'java', 'c', 'cpp', 'php', 'css',
126
+ ])
127
+
128
+ export default KEYWORDS