@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.
- package/LICENSE +21 -0
- package/README.md +629 -0
- package/examples/ai-rich.js +84 -0
- package/examples/button.js +57 -0
- package/examples/carousel.js +51 -0
- package/examples/interactive.js +102 -0
- package/examples/media.js +66 -0
- package/examples/simple-bot.js +56 -0
- package/package.json +86 -0
- package/src/builders/ai-rich.js +644 -0
- package/src/builders/base.js +109 -0
- package/src/builders/button-v2.js +159 -0
- package/src/builders/button.js +398 -0
- package/src/builders/card.js +168 -0
- package/src/builders/carousel.js +122 -0
- package/src/builders/index.d.ts +1 -0
- package/src/builders/index.js +13 -0
- package/src/client/bot.js +192 -0
- package/src/client/connection.js +180 -0
- package/src/errors.js +88 -0
- package/src/index.d.ts +458 -0
- package/src/index.js +152 -0
- package/src/media/fetch.js +67 -0
- package/src/media/image.js +86 -0
- package/src/media/index.d.ts +1 -0
- package/src/media/index.js +12 -0
- package/src/media/resolver.js +115 -0
- package/src/media/uploader.js +65 -0
- package/src/media/video.js +195 -0
- package/src/parsers/code-tokenizer-keywords.js +128 -0
- package/src/parsers/code-tokenizer.js +191 -0
- package/src/parsers/index.d.ts +1 -0
- package/src/parsers/index.js +11 -0
- package/src/parsers/inline-entity.js +231 -0
- package/src/parsers/table-metadata.js +69 -0
- package/src/proto/enums.js +170 -0
- package/src/proto/index.d.ts +1 -0
- package/src/proto/index.js +13 -0
- package/src/proto/layouts.js +89 -0
- package/src/proto/primitives.js +181 -0
- package/src/proto/relay-nodes.js +55 -0
- package/src/proto/rich-response.js +144 -0
- package/src/proto/updater.js +318 -0
- package/src/services/index.d.ts +1 -0
- package/src/services/index.js +10 -0
- package/src/services/media-service.js +184 -0
- package/src/services/message-service.js +288 -0
- package/src/services/proto-service.js +90 -0
- package/src/utils/id.js +42 -0
- package/src/utils/logger.js +65 -0
- package/src/utils/mime.js +104 -0
- package/src/utils/promise.js +52 -0
- 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
|