@nxtedition/lib 22.0.13 → 22.0.15

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 +50 -16
  2. package/package.json +4 -2
  3. package/wordwrap.js +128 -0
package/ass.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
  import fp from 'lodash/fp.js'
3
+ import { wordwrap } from './wordwrap.js'
3
4
 
4
5
  const BASE_WIDTH = 1920
5
6
  const BASE_HEIGHT = 1080
@@ -38,10 +39,11 @@ const BASE_HEIGHT = 1080
38
39
  * @property {string} [encoding]
39
40
  *
40
41
  * @typedef {object} Options
41
- * @property {Styles} [styles]
42
+ * @property {{ [styleName: string]: Styles }} [styles]
42
43
  * @property {number} [width]
43
44
  * @property {number} [height]
44
45
  * @property {boolean} [scaledBorderAndShadow] TODO: default to true when all client styles depend on it?
46
+ * @property {boolean} [futureWordWrapping]
45
47
  */
46
48
 
47
49
  /**
@@ -51,27 +53,54 @@ const BASE_HEIGHT = 1080
51
53
  */
52
54
  export function encodeASS(
53
55
  events,
54
- { styles = {}, width = BASE_WIDTH, height = BASE_HEIGHT, scaledBorderAndShadow = false } = {},
56
+ {
57
+ styles = {},
58
+ width = BASE_WIDTH,
59
+ height = BASE_HEIGHT,
60
+ scaledBorderAndShadow = false,
61
+ futureWordWrapping = false,
62
+ } = {},
55
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)
56
67
  return [
57
- encASSHeader({ width, height, scaledBorderAndShadow }),
68
+ encASSHeader({ playResX, playResY, scaledBorderAndShadow, futureWordWrapping }),
58
69
  encASSStyles(styles),
59
- encASSEvents(events),
70
+ encASSEvents(events, styles, playResX, futureWordWrapping),
60
71
  ].join('\n')
61
72
  }
62
73
 
63
74
  /**
64
75
  * @param {SubtitleEvent[]} events
76
+ * @param {{ [styleName: string]: Styles }} styles
77
+ * @param {number} scriptResX
78
+ * @param {boolean} futureWordWrapping
65
79
  */
66
- function formatDialogues(events) {
80
+ function formatDialogues(events, styles, scriptResX, futureWordWrapping) {
67
81
  let s = ''
68
82
  for (const { start, duration, end = duration != null ? start + duration : null, text, style } of [
69
83
  ...events,
70
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
+ )
71
93
  if (typeof text === 'string' && text.length > 0 && Number.isFinite(start)) {
72
94
  s += `Dialogue: 0,${formatASSTime(start) || '0:00:00.00'},${
73
95
  formatASSTime(end) || '9:59:59.00'
74
- },${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')}`
75
104
  }
76
105
  }
77
106
  return s
@@ -79,15 +108,18 @@ function formatDialogues(events) {
79
108
 
80
109
  /**
81
110
  * @param {SubtitleEvent[]} events
111
+ * @param {{ [styleName: string]: Styles }} styles
112
+ * @param {number} scriptResX
113
+ * @param {boolean} futureWordWrapping
82
114
  */
83
- function encASSEvents(events) {
115
+ function encASSEvents(events, styles, scriptResX, futureWordWrapping) {
84
116
  return `[Events]
85
117
  Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
86
- ${formatDialogues(events)}`
118
+ ${formatDialogues(events, styles, scriptResX, futureWordWrapping)}`
87
119
  }
88
120
 
89
121
  /**
90
- * @param {Styles} styles
122
+ * @param {{ [styleName: string]: Styles }} styles
91
123
  */
92
124
  const formatStyles = fp.pipe(
93
125
  fp.entries,
@@ -157,12 +189,12 @@ ${formatStyles(styles)}`
157
189
 
158
190
  /**
159
191
  * @param {object} params
160
- * @param {number} [params.width]
161
- * @param {number} [params.height]
192
+ * @param {number} params.playResX
193
+ * @param {number} params.playResY
162
194
  * @param {boolean} [params.scaledBorderAndShadow]
195
+ * @param {boolean} params.futureWordWrapping
163
196
  */
164
- function encASSHeader({ width, height, scaledBorderAndShadow }) {
165
- const scale = typeof width === 'number' ? BASE_WIDTH / width : 1
197
+ function encASSHeader({ playResX, playResY, scaledBorderAndShadow, futureWordWrapping }) {
166
198
  // WrapStyle
167
199
  // 0: smart wrapping, lines are evenly broken
168
200
  // 1: end-of-line word wrapping, only \N breaks
@@ -171,9 +203,11 @@ function encASSHeader({ width, height, scaledBorderAndShadow }) {
171
203
  const header = [
172
204
  '[Script Info]',
173
205
  'ScriptType: v4.00+',
174
- `PlayResX: ${scale * (width || BASE_WIDTH)}`,
175
- `PlayResY: ${scale * (height || BASE_HEIGHT)}`,
176
- 'WrapStyle: 1',
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}`,
177
211
  ]
178
212
  if (scaledBorderAndShadow) {
179
213
  header.push('ScaledBorderAndShadow: Yes')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/lib",
3
- "version": "22.0.13",
3
+ "version": "22.0.15",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "type": "module",
@@ -34,7 +34,8 @@
34
34
  "worker.js",
35
35
  "stream.js",
36
36
  "timeline.js",
37
- "docker-secrets.js"
37
+ "docker-secrets.js",
38
+ "wordwrap.js"
38
39
  ],
39
40
  "scripts": {
40
41
  "prepublishOnly": "pinst --disable",
@@ -59,6 +60,7 @@
59
60
  "@elastic/transport": "^8.9.1",
60
61
  "@nxtedition/nxt-undici": "^4.2.26",
61
62
  "@swc/wasm-web": "^1.10.1",
63
+ "canvas": "^3.1.0",
62
64
  "content-type": "^1.0.5",
63
65
  "date-fns": "^3.6.0",
64
66
  "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
+ }