@nxtedition/lib 23.11.2 → 23.12.1
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/ass.js +248 -85
- package/package.json +2 -1
package/ass.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import fp from 'lodash/fp.js'
|
|
3
2
|
import { wordwrap } from './wordwrap.js'
|
|
4
3
|
|
|
5
4
|
const BASE_WIDTH = 1920
|
|
@@ -12,6 +11,29 @@ const BASE_HEIGHT = 1080
|
|
|
12
11
|
* @property {number | null} [end]
|
|
13
12
|
* @property {string | null} [style]
|
|
14
13
|
* @property {string | null} [text]
|
|
14
|
+
* @property {SubtitleEventStyleOverrides} [styleOverrides]
|
|
15
|
+
*
|
|
16
|
+
* @typedef {object} SubtitleEventStyleOverrides
|
|
17
|
+
* @property {string} [marginL]
|
|
18
|
+
* @property {string} [marginR]
|
|
19
|
+
* @property {string} [marginV]
|
|
20
|
+
* @property {string} [alignment]
|
|
21
|
+
* @property {string} [fontname]
|
|
22
|
+
* @property {string} [fontsize]
|
|
23
|
+
* @property {string} [primaryColour]
|
|
24
|
+
* @property {string} [secondaryColour]
|
|
25
|
+
* @property {string} [outlineColour]
|
|
26
|
+
* @property {string} [backColour]
|
|
27
|
+
* @property {string} [bold]
|
|
28
|
+
* @property {string} [italic]
|
|
29
|
+
* @property {string} [underline]
|
|
30
|
+
* @property {string} [strikeOut]
|
|
31
|
+
*
|
|
32
|
+
* @typedef {object} EmbeddableSubtitleFontFace
|
|
33
|
+
* @property {string} family
|
|
34
|
+
* @property {'normal'|'bold'} weight
|
|
35
|
+
* @property {'normal'|'italic'} style
|
|
36
|
+
* @property {Uint8Array} data
|
|
15
37
|
*
|
|
16
38
|
* @typedef {object} Styles
|
|
17
39
|
* @property {string} [name]
|
|
@@ -44,6 +66,7 @@ const BASE_HEIGHT = 1080
|
|
|
44
66
|
* @property {number} [height]
|
|
45
67
|
* @property {boolean} [scaledBorderAndShadow] TODO: default to true when all client styles depend on it?
|
|
46
68
|
* @property {boolean} [futureWordWrapping]
|
|
69
|
+
* @property {EmbeddableSubtitleFontFace[]} [fonts]
|
|
47
70
|
*/
|
|
48
71
|
|
|
49
72
|
/**
|
|
@@ -59,6 +82,7 @@ export function encodeASS(
|
|
|
59
82
|
height = BASE_HEIGHT,
|
|
60
83
|
scaledBorderAndShadow = false,
|
|
61
84
|
futureWordWrapping = false,
|
|
85
|
+
fonts = [],
|
|
62
86
|
} = {},
|
|
63
87
|
) {
|
|
64
88
|
const scale = typeof width === 'number' ? BASE_HEIGHT / height : 1
|
|
@@ -66,9 +90,13 @@ export function encodeASS(
|
|
|
66
90
|
const playResY = scale * (height || BASE_HEIGHT)
|
|
67
91
|
return [
|
|
68
92
|
encASSHeader({ playResX, playResY, scaledBorderAndShadow, futureWordWrapping }),
|
|
93
|
+
encFontFaces(fonts),
|
|
69
94
|
encASSStyles(styles),
|
|
95
|
+
'',
|
|
70
96
|
encASSEvents(events, styles, playResX, futureWordWrapping),
|
|
71
|
-
]
|
|
97
|
+
]
|
|
98
|
+
.filter((x) => typeof x === 'string')
|
|
99
|
+
.join('\n')
|
|
72
100
|
}
|
|
73
101
|
|
|
74
102
|
/**
|
|
@@ -78,34 +106,111 @@ export function encodeASS(
|
|
|
78
106
|
* @param {boolean} futureWordWrapping
|
|
79
107
|
*/
|
|
80
108
|
function formatDialogues(events, styles, scriptResX, futureWordWrapping) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
return [...events]
|
|
110
|
+
.sort((a, b) => a.start - b.start)
|
|
111
|
+
.map((event) => {
|
|
112
|
+
const {
|
|
113
|
+
start,
|
|
114
|
+
duration,
|
|
115
|
+
end = duration != null ? start + duration : null,
|
|
116
|
+
text,
|
|
117
|
+
style,
|
|
118
|
+
styleOverrides,
|
|
119
|
+
} = event
|
|
120
|
+
|
|
121
|
+
if (typeof text !== 'string' || text.length === 0 || typeof start !== 'number') {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const styleName = style || 'nxt-default'
|
|
126
|
+
const eventStyle = styles[styleName] ?? {}
|
|
127
|
+
const textOptions = {
|
|
128
|
+
fontSize: parseFloat(eventStyle.fontsize || '32'),
|
|
129
|
+
fontFamily: eventStyle.fontname ?? 'Arial',
|
|
130
|
+
maxWidth: Math.max(
|
|
131
|
+
0,
|
|
132
|
+
scriptResX -
|
|
133
|
+
parseFloat(eventStyle.marginL || '0') -
|
|
134
|
+
parseFloat(eventStyle.marginR || '0'),
|
|
135
|
+
),
|
|
136
|
+
styleOverrides,
|
|
137
|
+
futureWordWrapping,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return `Dialogue: ${[
|
|
141
|
+
/* Layer */ '0',
|
|
142
|
+
/* Start */ formatASSTime(start) || '0:00:00.00',
|
|
143
|
+
/* End */ formatASSTime(end) || '9:59:59.00',
|
|
144
|
+
/* Style */ styleName,
|
|
145
|
+
/* Name */ '',
|
|
146
|
+
/* MarginL */ (styleOverrides?.marginL ?? '0').padStart(4, '0'),
|
|
147
|
+
/* MarginR */ (styleOverrides?.marginR ?? '0').padStart(4, '0'),
|
|
148
|
+
/* MarginV */ (styleOverrides?.marginV ?? '0').padStart(4, '0'),
|
|
149
|
+
/* Effect */ '',
|
|
150
|
+
/* Text */ encodeText(text, textOptions),
|
|
151
|
+
]
|
|
152
|
+
.filter((s) => typeof s === 'string')
|
|
153
|
+
.join(',')}`
|
|
154
|
+
})
|
|
155
|
+
.join('\n')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {string} text
|
|
160
|
+
* @param {object} options
|
|
161
|
+
* @param {number} options.fontSize
|
|
162
|
+
* @param {string} options.fontFamily
|
|
163
|
+
* @param {number} options.maxWidth
|
|
164
|
+
* @param {SubtitleEventStyleOverrides} [options.styleOverrides]
|
|
165
|
+
* @param {boolean} options.futureWordWrapping
|
|
166
|
+
*/
|
|
167
|
+
function encodeText(text, { styleOverrides, futureWordWrapping, ...options }) {
|
|
168
|
+
return `${getOverrideTags(styleOverrides)}${text
|
|
169
|
+
.split(/\\n|\n|\\N/)
|
|
170
|
+
.map((line) => line.trim())
|
|
171
|
+
.flatMap((line) =>
|
|
172
|
+
futureWordWrapping ? wordwrap(line, { ...options, breakWord: false }) : [line],
|
|
92
173
|
)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
174
|
+
.map((line) => line.trim())
|
|
175
|
+
.join('\\N')}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {SubtitleEventStyleOverrides} [styleOverrides]
|
|
180
|
+
*/
|
|
181
|
+
function getOverrideTags(styleOverrides) {
|
|
182
|
+
return Object.entries(styleOverrides ?? {})
|
|
183
|
+
.map(([key, value]) => {
|
|
184
|
+
switch (key) {
|
|
185
|
+
case 'alignment':
|
|
186
|
+
return `an${value}`
|
|
187
|
+
case 'primaryColour':
|
|
188
|
+
return `c${value}`
|
|
189
|
+
case 'secondaryColour':
|
|
190
|
+
return `2c${value}`
|
|
191
|
+
case 'outlineColour':
|
|
192
|
+
return `3c${value}`
|
|
193
|
+
case 'backColour':
|
|
194
|
+
return `4c${value}`
|
|
195
|
+
case 'bold':
|
|
196
|
+
return `b${value === '0' ? '0' : '1'}`
|
|
197
|
+
case 'italic':
|
|
198
|
+
return `i${value === '0' ? '0' : '1'}`
|
|
199
|
+
case 'underline':
|
|
200
|
+
return `u${value === '0' ? '0' : '1'}`
|
|
201
|
+
case 'strikeOut':
|
|
202
|
+
return `s${value === '0' ? '0' : '1'}`
|
|
203
|
+
case 'fontname':
|
|
204
|
+
return `fn${value}`
|
|
205
|
+
case 'fontsize':
|
|
206
|
+
return `fs${value}`
|
|
207
|
+
default:
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
.filter((tag) => typeof tag === 'string')
|
|
212
|
+
.map((tag) => `{\\${tag}}`)
|
|
213
|
+
.join('')
|
|
109
214
|
}
|
|
110
215
|
|
|
111
216
|
/**
|
|
@@ -117,70 +222,48 @@ function formatDialogues(events, styles, scriptResX, futureWordWrapping) {
|
|
|
117
222
|
function encASSEvents(events, styles, scriptResX, futureWordWrapping) {
|
|
118
223
|
return `[Events]
|
|
119
224
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
120
|
-
${formatDialogues(events, styles, scriptResX, futureWordWrapping)}`
|
|
225
|
+
${formatDialogues(events, styles, scriptResX, futureWordWrapping)}\n`
|
|
121
226
|
}
|
|
122
227
|
|
|
123
228
|
/**
|
|
124
229
|
* @param {{ [styleName: string]: Styles }} styles
|
|
230
|
+
* @returns {string}
|
|
125
231
|
*/
|
|
126
|
-
const formatStyles =
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
style.scaleY +
|
|
157
|
-
',' +
|
|
158
|
-
style.spacing +
|
|
159
|
-
',' +
|
|
160
|
-
style.angle +
|
|
161
|
-
',' +
|
|
162
|
-
style.borderStyle +
|
|
163
|
-
',' +
|
|
164
|
-
style.outline +
|
|
165
|
-
',' +
|
|
166
|
-
style.shadow +
|
|
167
|
-
',' +
|
|
168
|
-
style.alignment +
|
|
169
|
-
',' +
|
|
170
|
-
style.marginL +
|
|
171
|
-
',' +
|
|
172
|
-
style.marginR +
|
|
173
|
-
',' +
|
|
174
|
-
style.marginV +
|
|
175
|
-
',' +
|
|
176
|
-
style.encoding +
|
|
177
|
-
'\n'
|
|
232
|
+
const formatStyles = (styles) =>
|
|
233
|
+
Object.entries(styles)
|
|
234
|
+
.map(([id, style]) =>
|
|
235
|
+
[
|
|
236
|
+
`Style: ${id}`,
|
|
237
|
+
style.fontname,
|
|
238
|
+
style.fontsize,
|
|
239
|
+
style.primaryColour,
|
|
240
|
+
style.secondaryColour,
|
|
241
|
+
style.outlineColour,
|
|
242
|
+
style.backColour,
|
|
243
|
+
style.bold,
|
|
244
|
+
style.italic,
|
|
245
|
+
style.underline,
|
|
246
|
+
style.strikeOut,
|
|
247
|
+
style.scaleX,
|
|
248
|
+
style.scaleY,
|
|
249
|
+
style.spacing,
|
|
250
|
+
style.angle,
|
|
251
|
+
style.borderStyle,
|
|
252
|
+
style.outline,
|
|
253
|
+
style.shadow,
|
|
254
|
+
style.alignment,
|
|
255
|
+
style.marginL,
|
|
256
|
+
style.marginR,
|
|
257
|
+
style.marginV,
|
|
258
|
+
style.encoding,
|
|
259
|
+
]
|
|
260
|
+
.map((s) => s ?? '')
|
|
261
|
+
.join(','),
|
|
178
262
|
)
|
|
179
|
-
|
|
180
|
-
)
|
|
263
|
+
.join('\n')
|
|
181
264
|
|
|
182
265
|
/**
|
|
183
|
-
* @param {Styles} styles
|
|
266
|
+
* @param {{ [styleName: string]: Styles }} styles
|
|
184
267
|
* @returns {string}
|
|
185
268
|
*/
|
|
186
269
|
function encASSStyles(styles) {
|
|
@@ -195,6 +278,7 @@ ${formatStyles(styles)}`
|
|
|
195
278
|
* @param {number} params.playResY
|
|
196
279
|
* @param {boolean} [params.scaledBorderAndShadow]
|
|
197
280
|
* @param {boolean} params.futureWordWrapping
|
|
281
|
+
* @returns {string}
|
|
198
282
|
*/
|
|
199
283
|
function encASSHeader({ playResX, playResY, scaledBorderAndShadow, futureWordWrapping }) {
|
|
200
284
|
// WrapStyle
|
|
@@ -243,3 +327,82 @@ function formatASSTime(seconds) {
|
|
|
243
327
|
(cs === 0 ? '00' : cs < 10 ? '0' + cs : cs)
|
|
244
328
|
)
|
|
245
329
|
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @param {EmbeddableSubtitleFontFace[]} fontFaces
|
|
333
|
+
* @returns {string | undefined}
|
|
334
|
+
*/
|
|
335
|
+
function encFontFaces(fontFaces) {
|
|
336
|
+
if (fontFaces.length === 0) {
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
return [
|
|
340
|
+
'[Fonts]',
|
|
341
|
+
...fontFaces.map((fontFace) => {
|
|
342
|
+
return [`fontname: ${getFontName(fontFace)}`, ...uuencodeForSSA(fontFace.data)]
|
|
343
|
+
}),
|
|
344
|
+
]
|
|
345
|
+
.filter((x) => x !== undefined)
|
|
346
|
+
.flat()
|
|
347
|
+
.join('\n')
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @param {EmbeddableSubtitleFontFace} fontFace
|
|
352
|
+
*/
|
|
353
|
+
function getFontName(fontFace) {
|
|
354
|
+
const { family, weight, style } = fontFace
|
|
355
|
+
return `${family}_${weight === 'bold' ? 'B' : ''}${style === 'italic' ? 'I' : ''}0`
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* UUEncode a binary file (e.g. font file) for SSA/ASS embedding.
|
|
360
|
+
* @param {Uint8Array} data - Binary content of the file.
|
|
361
|
+
* @returns {string[]} - Encoded lines, each max 80 characters.
|
|
362
|
+
*/
|
|
363
|
+
function uuencodeForSSA(data) {
|
|
364
|
+
const result = []
|
|
365
|
+
const outputLineLength = 80
|
|
366
|
+
|
|
367
|
+
let i = 0
|
|
368
|
+
const fullChunks = Math.floor(data.length / 3)
|
|
369
|
+
|
|
370
|
+
// Encode 3-byte chunks
|
|
371
|
+
while (i < fullChunks * 3) {
|
|
372
|
+
const b1 = data[i++]
|
|
373
|
+
const b2 = data[i++]
|
|
374
|
+
const b3 = data[i++]
|
|
375
|
+
const n = (b1 << 16) | (b2 << 8) | b3
|
|
376
|
+
|
|
377
|
+
const c1 = ((n >> 18) & 0x3f) + 33
|
|
378
|
+
const c2 = ((n >> 12) & 0x3f) + 33
|
|
379
|
+
const c3 = ((n >> 6) & 0x3f) + 33
|
|
380
|
+
const c4 = (n & 0x3f) + 33
|
|
381
|
+
|
|
382
|
+
result.push(String.fromCharCode(c1, c2, c3, c4))
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Handle remainder
|
|
386
|
+
const remaining = data.length % 3
|
|
387
|
+
if (remaining === 1) {
|
|
388
|
+
const last = data[i] * 0x100
|
|
389
|
+
const c1 = ((last >> 10) & 0x3f) + 33
|
|
390
|
+
const c2 = ((last >> 4) & 0x3f) + 33
|
|
391
|
+
result.push(String.fromCharCode(c1, c2))
|
|
392
|
+
} else if (remaining === 2) {
|
|
393
|
+
const last = ((data[i] << 8) | data[i + 1]) * 0x10000
|
|
394
|
+
const c1 = ((last >> 16) & 0x3f) + 33
|
|
395
|
+
const c2 = ((last >> 10) & 0x3f) + 33
|
|
396
|
+
const c3 = ((last >> 4) & 0x3f) + 33
|
|
397
|
+
result.push(String.fromCharCode(c1, c2, c3))
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Break into 80-char lines
|
|
401
|
+
const finalLines = []
|
|
402
|
+
const allChars = result.join('')
|
|
403
|
+
for (let j = 0; j < allChars.length; j += outputLineLength) {
|
|
404
|
+
finalLines.push(allChars.slice(j, j + outputLineLength))
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return finalLines
|
|
408
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxtedition/lib",
|
|
3
|
-
"version": "23.
|
|
3
|
+
"version": "23.12.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Robert Nagy <robert.nagy@boffins.se>",
|
|
6
6
|
"type": "module",
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
"@nxtedition/deepstream.io-client-js": ">=28.1.15",
|
|
100
100
|
"@types/lodash": "^4.17.16",
|
|
101
101
|
"@types/node": "^22.15.1",
|
|
102
|
+
"canvas": "^3.1.0",
|
|
102
103
|
"eslint": "^9.25.1",
|
|
103
104
|
"eslint-config-prettier": "^10.1.2",
|
|
104
105
|
"eslint-config-standard": "^17.0.0",
|