akarisub 0.1.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.
@@ -0,0 +1,512 @@
1
+ /**
2
+ * Utility functions for AkariSub.
3
+ */
4
+
5
+ import type { SubtitleColorSpace, WebYCbCrColorSpace } from './types'
6
+
7
+ // =============================================================================
8
+ // Color Space Utilities
9
+ // =============================================================================
10
+
11
+ /** Map video color space to standard name */
12
+ export const webYCbCrMap: Record<string, WebYCbCrColorSpace> = {
13
+ bt709: 'BT709',
14
+ bt470bg: 'BT601', // BT.601 PAL
15
+ smpte170m: 'BT601' // BT.601 NTSC
16
+ }
17
+
18
+ /** Color matrix conversion values for SVG filter */
19
+ export const colorMatrixConversionMap: Record<string, Record<string, string>> = {
20
+ BT601: {
21
+ BT709: '1.0863 -0.0723 -0.014 0 0 0.0965 0.8451 0.0584 0 0 -0.0141 -0.0277 1.0418'
22
+ },
23
+ BT709: {
24
+ BT601: '0.9137 0.0784 0.0079 0 0 -0.1049 1.1722 -0.0671 0 0 0.0096 0.0322 0.9582'
25
+ },
26
+ FCC: {
27
+ BT709: '1.0873 -0.0736 -0.0137 0 0 0.0974 0.8494 0.0531 0 0 -0.0127 -0.0251 1.0378',
28
+ BT601: '1.001 -0.0008 -0.0002 0 0 0.0009 1.005 -0.006 0 0 0.0013 0.0027 0.996'
29
+ },
30
+ SMPTE240M: {
31
+ BT709: '0.9993 0.0006 0.0001 0 0 -0.0004 0.9812 0.0192 0 0 -0.0034 -0.0114 1.0148',
32
+ BT601: '0.913 0.0774 0.0096 0 0 -0.1051 1.1508 -0.0456 0 0 0.0063 0.0207 0.973'
33
+ }
34
+ }
35
+
36
+ /** libass YCbCr color space index map */
37
+ export const libassYCbCrMap: (SubtitleColorSpace | null)[] = [
38
+ null,
39
+ 'BT601',
40
+ null,
41
+ 'BT601',
42
+ 'BT601',
43
+ 'BT709',
44
+ 'BT709',
45
+ 'SMPTE240M',
46
+ 'SMPTE240M',
47
+ 'FCC',
48
+ 'FCC'
49
+ ]
50
+
51
+ /**
52
+ * Generate SVG filter URL for color space conversion.
53
+ */
54
+ export function getColorSpaceFilterUrl(
55
+ subtitleColorSpace: SubtitleColorSpace,
56
+ videoColorSpace: WebYCbCrColorSpace
57
+ ): string | null {
58
+ if (!subtitleColorSpace || !videoColorSpace) return null
59
+ if (subtitleColorSpace === videoColorSpace) return null
60
+
61
+ const matrix = colorMatrixConversionMap[subtitleColorSpace]?.[videoColorSpace]
62
+ if (!matrix) return null
63
+
64
+ return `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'><filter id='f'><feColorMatrix type='matrix' values='${matrix} 0 0 0 0 0 1 0'/></filter></svg>#f")`
65
+ }
66
+
67
+ // =============================================================================
68
+ // Canvas Utilities
69
+ // =============================================================================
70
+
71
+ /**
72
+ * Compute canvas size with prescaling.
73
+ */
74
+ export function computeCanvasSize(
75
+ width: number,
76
+ height: number,
77
+ prescaleFactor: number,
78
+ prescaleHeightLimit: number,
79
+ maxRenderHeight: number
80
+ ): { width: number; height: number } {
81
+ const scalefactor = prescaleFactor <= 0 ? 1.0 : prescaleFactor
82
+ const ratio = globalThis.devicePixelRatio || 1
83
+
84
+ if (height <= 0 || width <= 0) {
85
+ return { width: 0, height: 0 }
86
+ }
87
+
88
+ const sgn = scalefactor < 1 ? -1 : 1
89
+ let newH = height * ratio
90
+
91
+ if (sgn * newH * scalefactor <= sgn * prescaleHeightLimit) {
92
+ newH *= scalefactor
93
+ } else if (sgn * newH < sgn * prescaleHeightLimit) {
94
+ newH = prescaleHeightLimit
95
+ }
96
+
97
+ if (maxRenderHeight > 0 && newH > maxRenderHeight) {
98
+ newH = maxRenderHeight
99
+ }
100
+
101
+ width *= newH / height
102
+ height = newH
103
+
104
+ return { width, height }
105
+ }
106
+
107
+ /**
108
+ * Get video position and size accounting for aspect ratio.
109
+ */
110
+ export function getVideoPosition(
111
+ video: HTMLVideoElement,
112
+ videoWidth: number = video.videoWidth,
113
+ videoHeight: number = video.videoHeight
114
+ ): { width: number; height: number; x: number; y: number } {
115
+ const videoRatio = videoWidth / videoHeight
116
+ const { offsetWidth, offsetHeight } = video
117
+ const elementRatio = offsetWidth / offsetHeight
118
+
119
+ let width = offsetWidth
120
+ let height = offsetHeight
121
+
122
+ if (elementRatio > videoRatio) {
123
+ width = Math.floor(offsetHeight * videoRatio)
124
+ } else {
125
+ height = Math.floor(offsetWidth / videoRatio)
126
+ }
127
+
128
+ const x = (offsetWidth - width) / 2
129
+ const y = (offsetHeight - height) / 2
130
+
131
+ return { width, height, x, y }
132
+ }
133
+
134
+ // =============================================================================
135
+ // Alpha Bug Fix
136
+ // =============================================================================
137
+
138
+ /**
139
+ * Fix alpha bug in some browsers (transparent pixels rendered as non-black).
140
+ */
141
+ export function fixAlpha(uint8: Uint8ClampedArray, hasAlphaBug: boolean): Uint8ClampedArray {
142
+ if (!hasAlphaBug) return uint8
143
+
144
+ const len = uint8.length
145
+ const len4 = len - (len % 16) // Process 4 pixels at a time (16 bytes)
146
+
147
+ // Unrolled loop for 4 pixels at a time
148
+ let j = 3
149
+ for (; j < len4; j += 16) {
150
+ if (uint8[j] < 2) uint8[j] = 1
151
+ if (uint8[j + 4] < 2) uint8[j + 4] = 1
152
+ if (uint8[j + 8] < 2) uint8[j + 8] = 1
153
+ if (uint8[j + 12] < 2) uint8[j + 12] = 1
154
+ }
155
+
156
+ // Handle remaining pixels
157
+ for (; j < len; j += 4) {
158
+ if (uint8[j] < 2) uint8[j] = 1
159
+ }
160
+
161
+ return uint8
162
+ }
163
+
164
+ // =============================================================================
165
+ // ASS Parsing Utilities (for font detection)
166
+ // =============================================================================
167
+
168
+ interface ASSSection {
169
+ name: string
170
+ body: ASSBodyEntry[]
171
+ }
172
+
173
+ interface ASSBodyEntry {
174
+ type?: 'comment'
175
+ key?: string
176
+ value: string | string[] | Record<string, string>
177
+ }
178
+
179
+ /**
180
+ * Parse ASS file content.
181
+ * @param content - ASS file content
182
+ * @param stopAtEvents - Stop parsing when [Events] section is reached (for font detection)
183
+ */
184
+ export function parseAss(content: string, stopAtEvents: boolean = false): ASSSection[] {
185
+ const sections: ASSSection[] = []
186
+ const lines = content.split(/[\r\n]+/g)
187
+ const lineCount = lines.length
188
+ let format: string[] | null = null
189
+ let currentSection: ASSSection | null = null
190
+
191
+ for (let i = 0; i < lineCount; i++) {
192
+ const line = lines[i]
193
+ if (!line || /^\s*$/.test(line)) continue
194
+
195
+ const firstChar = line[0]
196
+
197
+ if (firstChar === '[') {
198
+ const m = line.match(/^\[(.*)\]$/)
199
+ if (m) {
200
+ // Early termination for font detection performance
201
+ if (stopAtEvents && m[1].toLowerCase() === 'events') {
202
+ break
203
+ }
204
+ format = null
205
+ currentSection = { name: m[1], body: [] }
206
+ sections.push(currentSection)
207
+ continue
208
+ }
209
+ }
210
+
211
+ if (!currentSection) continue
212
+
213
+ if (firstChar === ';') {
214
+ currentSection.body.push({
215
+ type: 'comment',
216
+ value: line.substring(1)
217
+ })
218
+ } else {
219
+ const colonIdx = line.indexOf(':')
220
+ if (colonIdx === -1) continue
221
+
222
+ const key = line.substring(0, colonIdx)
223
+ let value: string | string[] | Record<string, string> = line.substring(colonIdx + 1).trim()
224
+
225
+ if (format || key === 'Format') {
226
+ let valueArr = value.split(',')
227
+ if (format && valueArr.length > format.length) {
228
+ const lastPart = valueArr.slice(format.length - 1).join(',')
229
+ valueArr = valueArr.slice(0, format.length - 1)
230
+ valueArr.push(lastPart)
231
+ }
232
+
233
+ const arrLen = valueArr.length
234
+ for (let j = 0; j < arrLen; j++) {
235
+ valueArr[j] = valueArr[j].trim()
236
+ }
237
+
238
+ if (format) {
239
+ const tmp: Record<string, string> = {}
240
+ const formatLen = Math.min(format.length, arrLen)
241
+ for (let j = 0; j < formatLen; j++) {
242
+ tmp[format[j]] = valueArr[j]
243
+ }
244
+ value = tmp
245
+ } else {
246
+ value = valueArr
247
+ }
248
+ }
249
+
250
+ if (key === 'Format') {
251
+ format = value as string[]
252
+ }
253
+
254
+ currentSection.body.push({ key, value })
255
+ }
256
+ }
257
+
258
+ return sections
259
+ }
260
+
261
+ // =============================================================================
262
+ // ASS Content Transformation Utilities
263
+ // =============================================================================
264
+
265
+ const blurRegex = /\\blur(?:[0-9]+\.)?[0-9]+/gm
266
+
267
+ /**
268
+ * Remove all blur tags from subtitle content.
269
+ */
270
+ export function dropBlur(subContent: string): string {
271
+ return subContent.replace(blurRegex, '')
272
+ }
273
+
274
+ // Common video resolutions to detect source resolution
275
+ const commonResolutions = [
276
+ { w: 7680, h: 4320 }, // 8K
277
+ { w: 3840, h: 2160 }, // 4K UHD
278
+ { w: 2560, h: 1440 }, // 1440p
279
+ { w: 1920, h: 1080 }, // 1080p
280
+ { w: 1280, h: 720 } // 720p
281
+ ]
282
+
283
+ /**
284
+ * Detect the likely source resolution based on max position values.
285
+ */
286
+ function detectSourceResolution(maxX: number, maxY: number): { w: number; h: number } {
287
+ const sorted = [...commonResolutions].sort((a, b) => a.w - b.w)
288
+ for (const res of sorted) {
289
+ if (maxX <= res.w && maxY <= res.h) {
290
+ return res
291
+ }
292
+ }
293
+ return { w: Math.ceil(maxX / 100) * 100, h: Math.ceil(maxY / 100) * 100 }
294
+ }
295
+
296
+ function formatValue(value: number, original?: string): string | number {
297
+ const hasDecimal = original && original.includes('.')
298
+ return hasDecimal ? value.toFixed(2).replace(/\.?0+$/, '') : Math.round(value)
299
+ }
300
+
301
+ /**
302
+ * Scale override tags in Events from detected source resolution to PlayRes.
303
+ * Only scales tags within override blocks {...} in the Events section.
304
+ */
305
+ export function fixPlayRes(subContent: string): string {
306
+ const playResXMatch = subContent.match(/PlayResX:\s*(\d+)/i)
307
+ const playResYMatch = subContent.match(/PlayResY:\s*(\d+)/i)
308
+
309
+ const playResX = playResXMatch ? parseInt(playResXMatch[1], 10) : 1920
310
+ const playResY = playResYMatch ? parseInt(playResYMatch[1], 10) : 1080
311
+
312
+ const posRegex = /\\pos\s*\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)/g
313
+ const moveRegex = /\\move\s*\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)/g
314
+ const orgRegex = /\\org\s*\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)/g
315
+ const clipRectRegex = /\\i?clip\s*\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)/g
316
+
317
+ let maxX = 0
318
+ let maxY = 0
319
+
320
+ const findMax = (regex: RegExp, xIndices: number[], yIndices: number[]) => {
321
+ let match: RegExpExecArray | null
322
+ const regexCopy = new RegExp(regex.source, 'g')
323
+ while ((match = regexCopy.exec(subContent)) !== null) {
324
+ for (const i of xIndices) {
325
+ if (match[i]) {
326
+ const x = Math.abs(parseFloat(match[i]))
327
+ if (x > maxX) maxX = x
328
+ }
329
+ }
330
+ for (const i of yIndices) {
331
+ if (match[i]) {
332
+ const y = Math.abs(parseFloat(match[i]))
333
+ if (y > maxY) maxY = y
334
+ }
335
+ }
336
+ }
337
+ }
338
+
339
+ findMax(posRegex, [1], [2])
340
+ findMax(moveRegex, [1, 3], [2, 4])
341
+ findMax(orgRegex, [1], [2])
342
+ findMax(clipRectRegex, [1, 3], [2, 4])
343
+
344
+ if (maxX <= playResX && maxY <= playResY) return subContent
345
+
346
+ const sourceRes = detectSourceResolution(maxX, maxY)
347
+ const xnsize = playResX / sourceRes.w
348
+ const ynsize = playResY / sourceRes.h
349
+
350
+ const val = Math.min(xnsize, ynsize)
351
+ const val1 = Math.max(xnsize, ynsize)
352
+ const valFscx = 1.0
353
+
354
+ let newContent = subContent
355
+
356
+ const eventsMatch = newContent.match(/(\[Events\][\s\S]*)/i)
357
+ if (!eventsMatch) return newContent
358
+
359
+ let eventsSection = eventsMatch[1]
360
+
361
+ eventsSection = eventsSection.replace(
362
+ posRegex,
363
+ (_m, x, y) => `\\pos(${formatValue(parseFloat(x) * xnsize, x)},${formatValue(parseFloat(y) * ynsize, y)})`
364
+ )
365
+
366
+ eventsSection = eventsSection.replace(
367
+ /\\move\s*\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)(?:\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+))?\s*\)/g,
368
+ (_m, x1, y1, x2, y2, t1, t2) => {
369
+ const res = `\\move(${formatValue(parseFloat(x1) * xnsize, x1)},${formatValue(parseFloat(y1) * ynsize, y1)},${formatValue(parseFloat(x2) * xnsize, x2)},${formatValue(parseFloat(y2) * ynsize, y2)}`
370
+ return t1 ? `${res},${t1},${t2})` : `${res})`
371
+ }
372
+ )
373
+
374
+ eventsSection = eventsSection.replace(
375
+ orgRegex,
376
+ (_m, x, y) => `\\org(${formatValue(parseFloat(x) * xnsize, x)},${formatValue(parseFloat(y) * ynsize, y)})`
377
+ )
378
+
379
+ eventsSection = eventsSection.replace(
380
+ /\\(i?clip)\s*\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)/g,
381
+ (_m, type, x1, y1, x2, y2) =>
382
+ `\\${type}(${formatValue(parseFloat(x1) * xnsize, x1)},${formatValue(parseFloat(y1) * ynsize, y1)},${formatValue(parseFloat(x2) * xnsize, x2)},${formatValue(parseFloat(y2) * ynsize, y2)})`
383
+ )
384
+
385
+ eventsSection = eventsSection.replace(/\\fs([\d.]+)/g, (_m, s) => `\\fs${formatValue(parseFloat(s) * val1, s)}`)
386
+ eventsSection = eventsSection.replace(
387
+ /\\fscx([\d.]+)/g,
388
+ (_m, s) => `\\fscx${formatValue(parseFloat(s) * valFscx, s)}`
389
+ )
390
+ eventsSection = eventsSection.replace(
391
+ /\\xbord([\d.]+)/g,
392
+ (_m, s) => `\\xbord${formatValue(parseFloat(s) * xnsize, s)}`
393
+ )
394
+ eventsSection = eventsSection.replace(
395
+ /\\ybord([\d.]+)/g,
396
+ (_m, s) => `\\ybord${formatValue(parseFloat(s) * ynsize, s)}`
397
+ )
398
+ eventsSection = eventsSection.replace(
399
+ /\\xshad(-?[\d.]+)/g,
400
+ (_m, s) => `\\xshad${formatValue(parseFloat(s) * xnsize, s)}`
401
+ )
402
+ eventsSection = eventsSection.replace(
403
+ /\\yshad(-?[\d.]+)/g,
404
+ (_m, s) => `\\yshad${formatValue(parseFloat(s) * ynsize, s)}`
405
+ )
406
+
407
+ const minTags = ['fsp', 'bord', 'shad', 'be', 'blur']
408
+ minTags.forEach((tag) => {
409
+ const rgx = new RegExp(`\\\\${tag}(-?[\\d.]+)`, 'g')
410
+ eventsSection = eventsSection.replace(rgx, (_m, s) => `\\${tag}${formatValue(parseFloat(s) * val, s)}`)
411
+ })
412
+
413
+ eventsSection = eventsSection.replace(/(\\i?clip\s*\([^,)]+m[^)]+\)|\\p[1-9][^}]*?)(?=[\\}]|$)/g, (match) => {
414
+ return match.replace(/(-?[\d.]+)\s+(-?[\d.]+)/g, (_m, x, y) => {
415
+ return `${formatValue(parseFloat(x) * xnsize, x)} ${formatValue(parseFloat(y) * ynsize, y)}`
416
+ })
417
+ })
418
+
419
+ return newContent.substring(0, eventsMatch.index!) + eventsSection
420
+ }
421
+
422
+ // =============================================================================
423
+ // Feature Detection
424
+ // =============================================================================
425
+
426
+ let _hasAlphaBug: boolean | null = null
427
+ let _hasBitmapBug: boolean | null = null
428
+
429
+ /**
430
+ * Test for browser image bugs.
431
+ */
432
+ export async function testImageBugs(): Promise<{ hasAlphaBug: boolean; hasBitmapBug: boolean }> {
433
+ if (_hasAlphaBug !== null && _hasBitmapBug !== null) {
434
+ return { hasAlphaBug: _hasAlphaBug, hasBitmapBug: _hasBitmapBug }
435
+ }
436
+
437
+ const canvas1 = document.createElement('canvas')
438
+ const ctx1 = canvas1.getContext('2d', { willReadFrequently: true })
439
+ if (!ctx1) throw new Error('Canvas rendering not supported')
440
+
441
+ // Test ImageData constructor
442
+ if (typeof ImageData.prototype.constructor === 'function') {
443
+ try {
444
+ new ImageData(new Uint8ClampedArray([0, 0, 0, 0]), 1, 1)
445
+ } catch {
446
+ console.log('Detected that ImageData is not constructable despite browser saying so')
447
+ }
448
+ }
449
+
450
+ // Test for alpha bug
451
+ const canvas2 = document.createElement('canvas')
452
+ const ctx2 = canvas2.getContext('2d', { willReadFrequently: true })
453
+ if (!ctx2) throw new Error('Canvas rendering not supported')
454
+
455
+ canvas1.width = canvas2.width = 1
456
+ canvas1.height = canvas2.height = 1
457
+ ctx1.clearRect(0, 0, 1, 1)
458
+ ctx2.clearRect(0, 0, 1, 1)
459
+
460
+ const prePut = ctx2.getImageData(0, 0, 1, 1).data
461
+ ctx1.putImageData(new ImageData(new Uint8ClampedArray([0, 255, 0, 0]), 1, 1), 0, 0)
462
+ ctx2.drawImage(canvas1, 0, 0)
463
+ const postPut = ctx2.getImageData(0, 0, 1, 1).data
464
+
465
+ _hasAlphaBug = prePut[1] !== postPut[1]
466
+ if (_hasAlphaBug) {
467
+ console.log('Detected a browser having issue with transparent pixels, applying workaround')
468
+ }
469
+
470
+ // Test for bitmap bug
471
+ if (typeof createImageBitmap !== 'undefined') {
472
+ const subarray = new Uint8ClampedArray([255, 0, 255, 0, 255]).subarray(1, 5)
473
+ ctx2.drawImage(await createImageBitmap(new ImageData(subarray, 1)), 0, 0)
474
+ const { data } = ctx2.getImageData(0, 0, 1, 1)
475
+ _hasBitmapBug = false
476
+
477
+ for (let i = 0; i < data.length; i++) {
478
+ if (Math.abs(subarray[i] - data[i]) > 15) {
479
+ _hasBitmapBug = true
480
+ console.log('Detected a browser having issue with partial bitmaps, applying workaround')
481
+ break
482
+ }
483
+ }
484
+ } else {
485
+ _hasBitmapBug = false
486
+ }
487
+
488
+ canvas1.remove()
489
+ canvas2.remove()
490
+
491
+ return { hasAlphaBug: _hasAlphaBug, hasBitmapBug: _hasBitmapBug }
492
+ }
493
+
494
+ /**
495
+ * Run all feature detection tests.
496
+ */
497
+ export async function runFeatureTests(): Promise<{
498
+ hasAlphaBug: boolean
499
+ hasBitmapBug: boolean
500
+ }> {
501
+ return testImageBugs()
502
+ }
503
+
504
+ /** Get cached alpha bug value */
505
+ export function getAlphaBug(): boolean | null {
506
+ return _hasAlphaBug
507
+ }
508
+
509
+ /** Get cached bitmap bug value */
510
+ export function getBitmapBug(): boolean | null {
511
+ return _hasBitmapBug
512
+ }