@nxtedition/lib 23.0.6 → 23.0.8

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 (3) hide show
  1. package/ass.js +129 -31
  2. package/package.json +4 -2
  3. package/wordwrap.js +128 -0
package/ass.js CHANGED
@@ -1,36 +1,126 @@
1
+ // @ts-check
1
2
  import fp from 'lodash/fp.js'
3
+ import { wordwrap } from './wordwrap.js'
2
4
 
3
5
  const BASE_WIDTH = 1920
4
6
  const BASE_HEIGHT = 1080
5
7
 
6
- export function encodeASS(events, { styles = {}, width = BASE_WIDTH, height = BASE_HEIGHT } = {}) {
8
+ /**
9
+ * @typedef {object} SubtitleEvent
10
+ * @property {number} start
11
+ * @property {number | null} [duration]
12
+ * @property {number | null} [end]
13
+ * @property {string | null} [style]
14
+ * @property {string | null} [text]
15
+ *
16
+ * @typedef {object} Styles
17
+ * @property {string} [name]
18
+ * @property {string} [fontname]
19
+ * @property {string} [fontsize]
20
+ * @property {string} [primaryColour]
21
+ * @property {string} [secondaryColour]
22
+ * @property {string} [outlineColour]
23
+ * @property {string} [backColour]
24
+ * @property {string} [bold]
25
+ * @property {string} [italic]
26
+ * @property {string} [underline]
27
+ * @property {string} [strikeOut]
28
+ * @property {string} [scaleX]
29
+ * @property {string} [scaleY]
30
+ * @property {string} [spacing]
31
+ * @property {string} [angle]
32
+ * @property {string} [borderStyle]
33
+ * @property {string} [outline]
34
+ * @property {string} [shadow]
35
+ * @property {string} [alignment]
36
+ * @property {string} [marginL]
37
+ * @property {string} [marginR]
38
+ * @property {string} [marginV]
39
+ * @property {string} [encoding]
40
+ *
41
+ * @typedef {object} Options
42
+ * @property {{ [styleName: string]: Styles }} [styles]
43
+ * @property {number} [width]
44
+ * @property {number} [height]
45
+ * @property {boolean} [scaledBorderAndShadow] TODO: default to true when all client styles depend on it?
46
+ * @property {boolean} [futureWordWrapping]
47
+ */
48
+
49
+ /**
50
+ * @param {SubtitleEvent[]} events
51
+ * @param {Options} [options]
52
+ * @returns {string}
53
+ */
54
+ export function encodeASS(
55
+ events,
56
+ {
57
+ styles = {},
58
+ width = BASE_WIDTH,
59
+ height = BASE_HEIGHT,
60
+ scaledBorderAndShadow = false,
61
+ futureWordWrapping = false,
62
+ } = {},
63
+ ) {
64
+ const scale = typeof width === 'number' ? BASE_WIDTH / width : 1
65
+ const playResX = scale * (width || BASE_WIDTH)
66
+ const playResY = scale * (height || BASE_HEIGHT)
7
67
  return [
8
- encASSHeader({ width, height }),
9
- encASSStyles({ styles, width, height }),
10
- encASSEvents(events),
68
+ encASSHeader({ playResX, playResY, scaledBorderAndShadow, futureWordWrapping }),
69
+ encASSStyles(styles),
70
+ encASSEvents(events, styles, playResX, futureWordWrapping),
11
71
  ].join('\n')
12
72
  }
13
73
 
14
- function formatDialogues(events) {
74
+ /**
75
+ * @param {SubtitleEvent[]} events
76
+ * @param {{ [styleName: string]: Styles }} styles
77
+ * @param {number} scriptResX
78
+ * @param {boolean} futureWordWrapping
79
+ */
80
+ function formatDialogues(events, styles, scriptResX, futureWordWrapping) {
15
81
  let s = ''
16
82
  for (const { start, duration, end = duration != null ? start + duration : null, text, style } of [
17
83
  ...events,
18
84
  ].sort((a, b) => a.start - b.start)) {
85
+ const styleName = style || 'nxt-default'
86
+ const eventStyle = styles[styleName]
87
+ const fontSize = parseFloat(eventStyle.fontsize || '32')
88
+ const fontFamily = eventStyle.fontname ?? 'Arial'
89
+ const maxWidth = Math.max(
90
+ 0,
91
+ scriptResX - parseFloat(eventStyle.marginL || '0') - parseFloat(eventStyle.marginR || '0'),
92
+ )
19
93
  if (typeof text === 'string' && text.length > 0 && Number.isFinite(start)) {
20
94
  s += `Dialogue: 0,${formatASSTime(start) || '0:00:00.00'},${
21
95
  formatASSTime(end) || '9:59:59.00'
22
- },${style || 'nxt-default'},,0000,0000,0000,,${(text || '').replace(/\\n|\n/, '\\N')}\n`
96
+ },${styleName},,0000,0000,0000,,${text
97
+ .split(/\\n|\n|\\N/)
98
+ .map((line) => line.trim())
99
+ .flatMap((line) =>
100
+ futureWordWrapping ? wordwrap(line, fontSize, fontFamily, maxWidth) : [line],
101
+ )
102
+ .map((line) => line.trim())
103
+ .join('\\N')}`
23
104
  }
24
105
  }
25
106
  return s
26
107
  }
27
108
 
28
- function encASSEvents(events) {
109
+ /**
110
+ * @param {SubtitleEvent[]} events
111
+ * @param {{ [styleName: string]: Styles }} styles
112
+ * @param {number} scriptResX
113
+ * @param {boolean} futureWordWrapping
114
+ */
115
+ function encASSEvents(events, styles, scriptResX, futureWordWrapping) {
29
116
  return `[Events]
30
117
  Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
31
- ${formatDialogues(events)}`
118
+ ${formatDialogues(events, styles, scriptResX, futureWordWrapping)}`
32
119
  }
33
120
 
121
+ /**
122
+ * @param {{ [styleName: string]: Styles }} styles
123
+ */
34
124
  const formatStyles = fp.pipe(
35
125
  fp.entries,
36
126
  fp.reduce((s, [id, style]) => {
@@ -87,40 +177,48 @@ const formatStyles = fp.pipe(
87
177
  }, ''),
88
178
  )
89
179
 
90
- function encASSStyles({ styles, width, height }) {
91
- const scaledStyles = {}
92
-
93
- for (const [id, style] of Object.entries(styles)) {
94
- scaledStyles[id] = { ...style }
95
- for (const key of ['fontsize', 'marginV', 'marginL', 'marginR']) {
96
- const scale = key === 'fontsize' ? height / BASE_HEIGHT : width / BASE_WIDTH
97
-
98
- if (typeof style[key] === 'string') {
99
- const scaled = Number(style[key]) * scale
100
- scaledStyles[id][key] = String(Math.round(scaled * 10) / 10)
101
- }
102
- }
103
- }
104
-
180
+ /**
181
+ * @param {Styles} styles
182
+ * @returns {string}
183
+ */
184
+ function encASSStyles(styles) {
105
185
  return `[V4+ Styles]
106
186
  Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
107
- ${formatStyles(scaledStyles)}`
187
+ ${formatStyles(styles)}`
108
188
  }
109
189
 
110
- function encASSHeader({ width, height }) {
190
+ /**
191
+ * @param {object} params
192
+ * @param {number} params.playResX
193
+ * @param {number} params.playResY
194
+ * @param {boolean} [params.scaledBorderAndShadow]
195
+ * @param {boolean} params.futureWordWrapping
196
+ */
197
+ function encASSHeader({ playResX, playResY, scaledBorderAndShadow, futureWordWrapping }) {
111
198
  // WrapStyle
112
199
  // 0: smart wrapping, lines are evenly broken
113
200
  // 1: end-of-line word wrapping, only \N breaks
114
201
  // 2: no word wrapping, \n \N both breaks
115
202
  // 3: same as 0, but lower line gets wider.
116
- return `[Script Info]
117
- ScriptType: v4.00+
118
- PlayResX: ${width || BASE_WIDTH}
119
- PlayResY: ${height || BASE_HEIGHT}
120
- WrapStyle: 1
121
- `
203
+ const header = [
204
+ '[Script Info]',
205
+ 'ScriptType: v4.00+',
206
+ `PlayResX: ${playResX}`,
207
+ `PlayResY: ${playResY}`,
208
+ // new way is to not let ass wrap (2) -- we handle it ourselves
209
+ // to make sure hub and render gets the same result
210
+ `WrapStyle: ${futureWordWrapping ? 2 : 1}`,
211
+ ]
212
+ if (scaledBorderAndShadow) {
213
+ header.push('ScaledBorderAndShadow: Yes')
214
+ }
215
+ return `${header.join('\n')}\n`
122
216
  }
123
217
 
218
+ /**
219
+ * @param {number | null | undefined} seconds
220
+ * @returns {string}
221
+ */
124
222
  function formatASSTime(seconds) {
125
223
  if (seconds == null) {
126
224
  return ''
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/lib",
3
- "version": "23.0.6",
3
+ "version": "23.0.8",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "type": "module",
@@ -40,7 +40,8 @@
40
40
  "scheduler.js",
41
41
  "stream.js",
42
42
  "timeline.js",
43
- "docker-secrets.js"
43
+ "docker-secrets.js",
44
+ "wordwrap.js"
44
45
  ],
45
46
  "scripts": {
46
47
  "prepublishOnly": "pinst --disable",
@@ -65,6 +66,7 @@
65
66
  "@elastic/transport": "^8.9.3",
66
67
  "@nxtedition/nxt-undici": "^6.0.0",
67
68
  "@swc/wasm-web": "^1.10.1",
69
+ "canvas": "^3.1.0",
68
70
  "content-type": "^1.0.5",
69
71
  "date-fns": "^3.6.0",
70
72
  "fast-querystring": "^1.1.1",
package/wordwrap.js ADDED
@@ -0,0 +1,128 @@
1
+ // @ts-check
2
+ import { createCanvas } from 'canvas'
3
+
4
+ const canvas = createCanvas(0, 0)
5
+ const ctx = canvas.getContext('2d')
6
+
7
+ /**
8
+ * Takes a string and a maxWidth and splits the text into lines.
9
+ *
10
+ * @param {string} text
11
+ * @param {number} fontSize
12
+ * @param {string} fontFamily
13
+ * @param {number} maxWidth
14
+ */
15
+ export function wordwrap(text, fontSize, fontFamily, maxWidth) {
16
+ // TODO: Register fonts
17
+ // const fontFile = name =>
18
+ // path.join(path.dirname(__dirname), 'fonts', `${name}.ttf`)
19
+ // const ptSans = new Canvas.Font('PTSans', fontFile('ptsans-regular'))
20
+ // ctx.addFont(ptSans)
21
+
22
+ ctx.font = `${fontSize}px ${fontFamily}`
23
+ const emMeasure = ctx.measureText('M').width
24
+ const spaceMeasure = ctx.measureText(' ').width
25
+
26
+ if (maxWidth < emMeasure) {
27
+ // To prevent weird looping anamolies farther on.
28
+ return text.split('')
29
+ }
30
+
31
+ if (ctx.measureText(text).width < maxWidth) {
32
+ return [text]
33
+ }
34
+
35
+ const words = text.split(' ')
36
+ const metawords = []
37
+ const lines = []
38
+
39
+ // measure first.
40
+ for (const w in words) {
41
+ const word = words[w]
42
+ const measure = ctx.measureText(word).width
43
+
44
+ // Edge case - If the current word is too long for one line, break it into maximized pieces.
45
+ if (measure > maxWidth) {
46
+ // TODO - a divide and conquer method might be nicer.
47
+ const edgewords = splitWord(word, maxWidth)
48
+
49
+ // could use metawords = metawords.concat(edgwords)
50
+ for (const ew in edgewords) {
51
+ metawords.push(edgewords[ew])
52
+ }
53
+ } else {
54
+ metawords.push({ word, len: word.length, measure })
55
+ }
56
+ }
57
+
58
+ // build array of lines second.
59
+ let cline = ''
60
+ let cmeasure = 0
61
+ for (const mw in metawords) {
62
+ const metaword = metawords[mw]
63
+
64
+ // If current word doesn't fit on current line, push the current line and start a new one.
65
+ // Unless (edge-case): this is a new line and the current word is one character.
66
+ if (cmeasure + metaword.measure > maxWidth && cmeasure > 0 && metaword.len > 1) {
67
+ lines.push(cline)
68
+ cline = ''
69
+ cmeasure = 0
70
+ }
71
+
72
+ cline += metaword.word
73
+ cmeasure += metaword.measure
74
+
75
+ // If there's room, append a space, else push the current line and start a new one.
76
+ if (cmeasure + spaceMeasure < maxWidth) {
77
+ cline += ' '
78
+ cmeasure += spaceMeasure
79
+ } else {
80
+ lines.push(cline)
81
+ cline = ''
82
+ cmeasure = 0
83
+ }
84
+ }
85
+ if (cmeasure > 0) {
86
+ lines.push(cline)
87
+ }
88
+
89
+ return lines
90
+ }
91
+
92
+ /**
93
+ * @param {string} word
94
+ * @param {number} maxWidth
95
+ */
96
+ function splitWord(word, maxWidth) {
97
+ const wlen = word.length
98
+ if (wlen === 0) {
99
+ return []
100
+ }
101
+
102
+ const awords = []
103
+ const letters = []
104
+ let cword = ''
105
+ let cmeasure = 0
106
+
107
+ // Measure each letter.
108
+ for (let l = 0; l < wlen; l += 1) {
109
+ letters.push({ letter: word[l], measure: ctx.measureText(word[l]).width })
110
+ }
111
+
112
+ // Assemble the letters into words of maximized length.
113
+ for (const ml in letters) {
114
+ const metaletter = letters[ml]
115
+
116
+ if (cmeasure + metaletter.measure > maxWidth) {
117
+ awords.push({ word: cword, len: cword.length, measure: cmeasure })
118
+ cword = ''
119
+ cmeasure = 0
120
+ }
121
+
122
+ cword += metaletter.letter
123
+ cmeasure += metaletter.measure
124
+ }
125
+ // there will always be one more word to push.
126
+ awords.push({ word: cword, len: cword.length, measure: cmeasure })
127
+ return awords
128
+ }