@dan-uni/dan-any 0.1.0 → 0.2.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/README.md +10 -1
- package/dist/index.js +5956 -1
- package/dist/index.umd.min.js +19951 -1
- package/dist/index.umd.min.js.LICENSE.txt +17 -0
- package/dist/src/ass-gen/__tests__/generate.test.d.ts +1 -0
- package/dist/src/ass-gen/ass/create.d.ts +4 -0
- package/dist/src/ass-gen/ass/dialogue.d.ts +17 -0
- package/dist/src/ass-gen/ass/event.d.ts +3 -0
- package/dist/src/ass-gen/ass/info.d.ts +8 -0
- package/dist/src/ass-gen/ass/raw.d.ts +10 -0
- package/dist/src/ass-gen/ass/style.d.ts +3 -0
- package/dist/src/ass-gen/config.d.ts +3 -0
- package/dist/src/ass-gen/index.d.ts +26 -0
- package/dist/src/ass-gen/types.d.ts +47 -0
- package/dist/src/ass-gen/util/color.d.ts +18 -0
- package/dist/src/ass-gen/util/danconvert.d.ts +4 -0
- package/dist/src/ass-gen/util/index.d.ts +4 -0
- package/dist/src/ass-gen/util/lang.d.ts +3 -0
- package/dist/src/ass-gen/util/layout.d.ts +4 -0
- package/dist/{index.d.ts → src/index.d.ts} +15 -1
- package/dist/src/index.test.d.ts +1 -0
- package/dist/{utils → src/utils}/dm-gen.d.ts +13 -5
- package/dist/src/utils/dm-gen.test.d.ts +1 -0
- package/package.json +9 -7
- package/rslib.config.ts +81 -0
- package/src/ass-gen/__tests__/898651903.xml +1619 -0
- package/src/ass-gen/__tests__/898651903.xml.ass +1392 -0
- package/src/ass-gen/__tests__/canvas.test.ts +11 -0
- package/src/ass-gen/__tests__/generate.test.ts +20 -0
- package/src/ass-gen/ass/create.ts +26 -0
- package/src/ass-gen/ass/dialogue.ts +91 -0
- package/src/ass-gen/ass/event.ts +58 -0
- package/src/ass-gen/ass/info.ts +28 -0
- package/src/ass-gen/ass/raw.ts +69 -0
- package/src/ass-gen/ass/style.ts +67 -0
- package/src/ass-gen/config.ts +45 -0
- package/src/ass-gen/index.ts +52 -0
- package/src/ass-gen/types.ts +52 -0
- package/src/ass-gen/util/color.ts +55 -0
- package/src/ass-gen/util/danconvert.ts +36 -0
- package/src/ass-gen/util/index.ts +10 -0
- package/src/ass-gen/util/lang.ts +35 -0
- package/src/ass-gen/util/layout.ts +238 -0
- package/src/index.test.ts +9 -0
- package/src/index.ts +111 -0
- package/src/utils/dm-gen.test.ts +66 -0
- package/src/utils/dm-gen.ts +91 -11
- package/tsconfig.json +7 -7
- package/dist/index.min.js +0 -1
- package/rollup.config.mjs +0 -51
- /package/dist/{index.test.d.ts → src/ass-gen/__tests__/canvas.test.d.ts} +0 -0
- /package/dist/{proto → src/proto}/gen/bili/dm_pb.d.ts +0 -0
- /package/dist/{proto → src/proto}/gen/danuni_pb.d.ts +0 -0
- /package/dist/{utils → src/utils}/id-gen.d.ts +0 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { assertType, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { measureTextWidth } from '../util/layout'
|
|
4
|
+
|
|
5
|
+
it('canvas measureTextWidth', () => {
|
|
6
|
+
const text = '一段测试文字'
|
|
7
|
+
const width = measureTextWidth('SimHei', 25, false, text)
|
|
8
|
+
assertType<number>(width)
|
|
9
|
+
console.info(width, text.length)
|
|
10
|
+
// assert(width >= 25 * text.length)
|
|
11
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path, { dirname } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { generateASS } from '../'
|
|
7
|
+
import { UniPool } from '../..'
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
|
|
11
|
+
it('generate ass from xml', () => {
|
|
12
|
+
const filename = '898651903.xml'
|
|
13
|
+
const xmlPath = path.join(__dirname, filename)
|
|
14
|
+
const xmlText = fs.readFileSync(xmlPath, 'utf-8')
|
|
15
|
+
const assText = generateASS(UniPool.fromBiliXML(xmlText), {
|
|
16
|
+
// filename,
|
|
17
|
+
// title: '我的忏悔',
|
|
18
|
+
})
|
|
19
|
+
fs.writeFileSync(path.join(__dirname, `${filename}.ass`), assText, 'utf-8')
|
|
20
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { UniPool } from '../..'
|
|
2
|
+
import type { Context, SubtitleStyle } from '../types'
|
|
3
|
+
|
|
4
|
+
import { UniPool2DanmakuLists } from '../util'
|
|
5
|
+
import event from './event'
|
|
6
|
+
import info from './info'
|
|
7
|
+
import raw from './raw'
|
|
8
|
+
import style from './style'
|
|
9
|
+
|
|
10
|
+
// eslint-disable-next-line import/no-default-export
|
|
11
|
+
export default (
|
|
12
|
+
list: UniPool,
|
|
13
|
+
rawList: UniPool,
|
|
14
|
+
config: SubtitleStyle,
|
|
15
|
+
context: Context = { filename: 'unknown', title: 'unknown' },
|
|
16
|
+
) => {
|
|
17
|
+
const Elist = UniPool2DanmakuLists(list),
|
|
18
|
+
ErawList = UniPool2DanmakuLists(rawList)
|
|
19
|
+
const content = [info(config, context), style(config), event(Elist, config)]
|
|
20
|
+
|
|
21
|
+
if (config.includeRaw) {
|
|
22
|
+
content.push(raw(ErawList, config, context))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return `${content.join('\n\n')}\n`
|
|
26
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { RGB } from '../types'
|
|
2
|
+
|
|
3
|
+
import { DanmakuType } from '../types'
|
|
4
|
+
import { formatColor, getDecoratingColor, isWhite } from '../util'
|
|
5
|
+
|
|
6
|
+
// Dialogue: 0,0:00:00.00,0:00:08.00,R2L,,20,20,2,,{\move(622.5,25,-62.5,25)}标准小裤裤
|
|
7
|
+
// Dialogue: 0,0:00:08.35,0:00:12.35,Fix,,20,20,2,,{\pos(280,50)\c&HEAA000}没男主吗
|
|
8
|
+
|
|
9
|
+
const formatTime = (seconds: number) => {
|
|
10
|
+
const div = (i: number, j: number) => Math.floor(i / j)
|
|
11
|
+
const pad = (n: number) => (n < 10 ? `0${n}` : String(n))
|
|
12
|
+
|
|
13
|
+
const integer = Math.floor(seconds)
|
|
14
|
+
const hour = div(integer, 60 * 60)
|
|
15
|
+
const minute = div(integer, 60) % 60
|
|
16
|
+
const second = integer % 60
|
|
17
|
+
const minorSecond = Math.floor((seconds - integer) * 100) // 取小数部分2位
|
|
18
|
+
|
|
19
|
+
return `${hour}:${pad(minute)}:${pad(second)}.${minorSecond}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const encode = (text: string) =>
|
|
23
|
+
text
|
|
24
|
+
.toString()
|
|
25
|
+
.replaceAll('{', '{')
|
|
26
|
+
.replaceAll('}', '}')
|
|
27
|
+
.replaceAll(/\r|\n/g, '')
|
|
28
|
+
|
|
29
|
+
const scrollCommand = ({
|
|
30
|
+
start,
|
|
31
|
+
end,
|
|
32
|
+
top,
|
|
33
|
+
}: {
|
|
34
|
+
start: number
|
|
35
|
+
end: number
|
|
36
|
+
top: number
|
|
37
|
+
}) => `\\move(${start},${top},${end},${top})`
|
|
38
|
+
const fixCommand = ({ top, left }: { top: number; left: number }) =>
|
|
39
|
+
`\\an8\\pos(${left},${top})`
|
|
40
|
+
const colorCommand = (color: RGB) => `\\c${formatColor(color)}`
|
|
41
|
+
const borderColorCommand = (color: RGB) => `\\3c${formatColor(color)}`
|
|
42
|
+
|
|
43
|
+
// eslint-disable-next-line import/no-default-export
|
|
44
|
+
export default (
|
|
45
|
+
danmaku: {
|
|
46
|
+
type: (typeof DanmakuType)[keyof typeof DanmakuType]
|
|
47
|
+
color: RGB
|
|
48
|
+
fontSizeType: number
|
|
49
|
+
content: string
|
|
50
|
+
time: number
|
|
51
|
+
start: number
|
|
52
|
+
end: number
|
|
53
|
+
top: number
|
|
54
|
+
left: number
|
|
55
|
+
},
|
|
56
|
+
config: { scrollTime: number; fixTime: number },
|
|
57
|
+
) => {
|
|
58
|
+
const { fontSizeType, content, time } = danmaku
|
|
59
|
+
const { scrollTime, fixTime } = config
|
|
60
|
+
|
|
61
|
+
const commands = [
|
|
62
|
+
danmaku.type === DanmakuType.SCROLL
|
|
63
|
+
? scrollCommand({
|
|
64
|
+
start: danmaku.start,
|
|
65
|
+
end: danmaku.end,
|
|
66
|
+
top: danmaku.top,
|
|
67
|
+
})
|
|
68
|
+
: fixCommand({ top: danmaku.top, left: danmaku.left }),
|
|
69
|
+
// 所有网站的原始默认色是白色,所以白色的时候不用额外加和颜色相关的指令
|
|
70
|
+
isWhite(danmaku.color) ? '' : colorCommand(danmaku.color),
|
|
71
|
+
isWhite(danmaku.color)
|
|
72
|
+
? ''
|
|
73
|
+
: borderColorCommand(getDecoratingColor(danmaku.color)),
|
|
74
|
+
]
|
|
75
|
+
const fields = [
|
|
76
|
+
0, // Layer,
|
|
77
|
+
formatTime(time), // Start
|
|
78
|
+
formatTime(
|
|
79
|
+
time + (danmaku.type === DanmakuType.SCROLL ? scrollTime : fixTime),
|
|
80
|
+
), // End
|
|
81
|
+
`F${fontSizeType}`, // Style
|
|
82
|
+
'', // Name
|
|
83
|
+
'0000', // MarginL
|
|
84
|
+
'0000', // MarginR
|
|
85
|
+
'0000', // MarginV
|
|
86
|
+
'', // Effect
|
|
87
|
+
`{${commands.join('')}}${encode(content)}`, // Text
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
return `Dialogue: ${fields.join(',')}`
|
|
91
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Danmaku, SubtitleStyle } from '../types'
|
|
2
|
+
|
|
3
|
+
import { DanmakuType } from '../types'
|
|
4
|
+
import dialogue from './dialogue'
|
|
5
|
+
|
|
6
|
+
const calculateDanmakuPosition = (danmaku: Danmaku, config: SubtitleStyle) => {
|
|
7
|
+
const { playResX, playResY, scrollTime, fixTime } = config
|
|
8
|
+
|
|
9
|
+
switch (danmaku.type) {
|
|
10
|
+
case DanmakuType.SCROLL: {
|
|
11
|
+
const start = playResX
|
|
12
|
+
const end = -playResX / 10 // Some extra space for complete exit
|
|
13
|
+
const top = (danmaku.fontSizeType * playResY) / 20
|
|
14
|
+
return {
|
|
15
|
+
...danmaku,
|
|
16
|
+
start,
|
|
17
|
+
end,
|
|
18
|
+
top,
|
|
19
|
+
left: 0,
|
|
20
|
+
duration: scrollTime,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
case DanmakuType.TOP:
|
|
24
|
+
case DanmakuType.BOTTOM: {
|
|
25
|
+
const left = playResX / 2
|
|
26
|
+
const top =
|
|
27
|
+
danmaku.type === DanmakuType.TOP
|
|
28
|
+
? (danmaku.fontSizeType * playResY) / 20
|
|
29
|
+
: playResY -
|
|
30
|
+
config.bottomSpace -
|
|
31
|
+
(danmaku.fontSizeType * playResY) / 20
|
|
32
|
+
return {
|
|
33
|
+
...danmaku,
|
|
34
|
+
start: 0,
|
|
35
|
+
end: 0,
|
|
36
|
+
top,
|
|
37
|
+
left,
|
|
38
|
+
duration: fixTime,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
default:
|
|
42
|
+
throw new Error(`Unknown danmaku type: ${danmaku.type}`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// eslint-disable-next-line import/no-default-export
|
|
47
|
+
export default (list: Danmaku[], config: SubtitleStyle) => {
|
|
48
|
+
const content = [
|
|
49
|
+
'[Events]',
|
|
50
|
+
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
51
|
+
...list.map((danmaku) => {
|
|
52
|
+
const positionedDanmaku = calculateDanmakuPosition(danmaku, config)
|
|
53
|
+
return dialogue(positionedDanmaku, config)
|
|
54
|
+
}),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
return content.join('\n')
|
|
58
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Context } from '../types'
|
|
2
|
+
|
|
3
|
+
import pkg from '../../../package.json' with { type: 'json' }
|
|
4
|
+
|
|
5
|
+
type ExtraInfo = Context
|
|
6
|
+
|
|
7
|
+
type Resolution = {
|
|
8
|
+
playResX: number
|
|
9
|
+
playResY: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// eslint-disable-next-line import/no-default-export
|
|
13
|
+
export default (
|
|
14
|
+
{ playResX, playResY }: Resolution,
|
|
15
|
+
{ filename, title }: ExtraInfo,
|
|
16
|
+
) => {
|
|
17
|
+
const content = [
|
|
18
|
+
'[Script Info]',
|
|
19
|
+
`Title: ${title}`,
|
|
20
|
+
`Original Script: 根据 ${filename} 的弹幕信息,由 ${pkg.homepage} 生成`,
|
|
21
|
+
'ScriptType: v4.00+',
|
|
22
|
+
'Collisions: Reverse',
|
|
23
|
+
`PlayResX: ${playResX}`,
|
|
24
|
+
`PlayResY: ${playResY}`,
|
|
25
|
+
'Timer: 100.0000',
|
|
26
|
+
]
|
|
27
|
+
return content.join('\n')
|
|
28
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { brotliCompressSync, brotliDecompressSync, gzipSync } from 'node:zlib'
|
|
2
|
+
import * as base16384 from 'base16384'
|
|
3
|
+
import type { Context, Danmaku, SubtitleStyle } from '../types'
|
|
4
|
+
|
|
5
|
+
type compressType = 'brotli' | 'gzip'
|
|
6
|
+
type baseType = 'base64' | 'base18384'
|
|
7
|
+
const compressTypes = ['brotli', 'gzip'],
|
|
8
|
+
baseTypes = ['base64', 'base18384']
|
|
9
|
+
|
|
10
|
+
function fromUint16Array(array: Uint16Array): string {
|
|
11
|
+
let result = ''
|
|
12
|
+
for (const element of array) {
|
|
13
|
+
result += String.fromCharCode(element)
|
|
14
|
+
}
|
|
15
|
+
return result
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// eslint-disable-next-line import/no-default-export
|
|
19
|
+
export default (
|
|
20
|
+
list: Danmaku[],
|
|
21
|
+
config: SubtitleStyle,
|
|
22
|
+
context: Context,
|
|
23
|
+
compressType: compressType = 'brotli',
|
|
24
|
+
baseType: baseType = 'base18384',
|
|
25
|
+
) => {
|
|
26
|
+
const raw = { list, config, context },
|
|
27
|
+
rawText = JSON.stringify(raw)
|
|
28
|
+
let compress = Buffer.from('')
|
|
29
|
+
if (compressType === 'brotli') compress = brotliCompressSync(rawText)
|
|
30
|
+
else compress = gzipSync(rawText)
|
|
31
|
+
return `;RawCompressType: ${compressType}\n;RawBaseType: ${baseType}\n;Raw: ${baseType === 'base64' ? compress.toString('base64') : fromUint16Array(base16384.encode(compress))}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function deRaw(ass: string):
|
|
35
|
+
| {
|
|
36
|
+
list: Danmaku[]
|
|
37
|
+
config: SubtitleStyle
|
|
38
|
+
context: Context
|
|
39
|
+
}
|
|
40
|
+
| undefined {
|
|
41
|
+
const arr = ass.split('\n'),
|
|
42
|
+
lineCompressType = arr.find((line) => line.startsWith(';RawCompressType:')),
|
|
43
|
+
lineBaseType = arr.find((line) => line.startsWith(';RawBaseType:')),
|
|
44
|
+
lineRaw = arr.find((line) => line.startsWith(';Raw:'))
|
|
45
|
+
if (!lineCompressType || !lineBaseType || !lineRaw) return undefined
|
|
46
|
+
else {
|
|
47
|
+
let compressType = lineCompressType
|
|
48
|
+
.replace(';RawCompressType: ', '')
|
|
49
|
+
.trim(),
|
|
50
|
+
baseType = lineBaseType.replace(';RawBaseType: ', '').trim()
|
|
51
|
+
if (!compressTypes.includes(compressType)) compressType = 'gzip'
|
|
52
|
+
if (!baseTypes.includes(baseType)) baseType = 'base64'
|
|
53
|
+
const text = lineRaw.replace(';Raw: ', '').trim(),
|
|
54
|
+
buffer =
|
|
55
|
+
baseType === 'base64'
|
|
56
|
+
? Buffer.from(text, 'base64')
|
|
57
|
+
: Buffer.from(
|
|
58
|
+
base16384.decode(Buffer.from(text, 'utf-8').toString('utf-8')),
|
|
59
|
+
)
|
|
60
|
+
let decompress = Buffer.from('')
|
|
61
|
+
if (compressType === 'brotli') decompress = brotliDecompressSync(buffer)
|
|
62
|
+
else decompress = gzipSync(buffer)
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(decompress.toString('utf-8'))
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { SubtitleStyle } from '../types'
|
|
2
|
+
|
|
3
|
+
import { formatColor, getDecoratingColor, hexColorToRGB } from '../util'
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line import/no-default-export
|
|
6
|
+
export default ({
|
|
7
|
+
fontName,
|
|
8
|
+
fontSize,
|
|
9
|
+
color: configColor,
|
|
10
|
+
outlineColor,
|
|
11
|
+
backColor,
|
|
12
|
+
bold,
|
|
13
|
+
outline,
|
|
14
|
+
shadow,
|
|
15
|
+
opacity,
|
|
16
|
+
}: SubtitleStyle) => {
|
|
17
|
+
const fields = [
|
|
18
|
+
'Name',
|
|
19
|
+
'Fontname',
|
|
20
|
+
'Fontsize',
|
|
21
|
+
'PrimaryColour',
|
|
22
|
+
'SecondaryColour',
|
|
23
|
+
'OutlineColour',
|
|
24
|
+
'BackColour',
|
|
25
|
+
'Bold',
|
|
26
|
+
'Italic',
|
|
27
|
+
'Underline',
|
|
28
|
+
'StrikeOut',
|
|
29
|
+
'ScaleX',
|
|
30
|
+
'ScaleY',
|
|
31
|
+
'Spacing',
|
|
32
|
+
'Angle',
|
|
33
|
+
'BorderStyle',
|
|
34
|
+
'Outline',
|
|
35
|
+
'Shadow',
|
|
36
|
+
'Alignment',
|
|
37
|
+
'MarginL',
|
|
38
|
+
'MarginR',
|
|
39
|
+
'MarginV',
|
|
40
|
+
'Encoding',
|
|
41
|
+
]
|
|
42
|
+
// 默认白底黑框
|
|
43
|
+
const primaryColorValue = formatColor(hexColorToRGB(configColor), opacity)
|
|
44
|
+
// 边框和阴影颜色没给的话算一个出来,不是黑就是白
|
|
45
|
+
const secondaryColor = getDecoratingColor(hexColorToRGB(configColor))
|
|
46
|
+
const outlineColorValue = formatColor(
|
|
47
|
+
outlineColor ? hexColorToRGB(outlineColor) : secondaryColor,
|
|
48
|
+
opacity,
|
|
49
|
+
)
|
|
50
|
+
const backColorValue = formatColor(
|
|
51
|
+
backColor ? hexColorToRGB(backColor) : secondaryColor,
|
|
52
|
+
opacity,
|
|
53
|
+
)
|
|
54
|
+
const colorStyle = `${primaryColorValue},${primaryColorValue},${outlineColorValue},${backColorValue}`
|
|
55
|
+
|
|
56
|
+
const boldValue = bold ? '1' : '0'
|
|
57
|
+
const fontStyle = `${boldValue},0,0,0,100,100,0,0,1,${outline},${shadow},7,0,0,0,0`
|
|
58
|
+
|
|
59
|
+
const fontDeclaration = (size: number, i: number) =>
|
|
60
|
+
`Style: F${i},${fontName},${size},${colorStyle},${fontStyle}`
|
|
61
|
+
const content = [
|
|
62
|
+
'[V4+ Styles]',
|
|
63
|
+
`Format: ${fields.join(',')}`,
|
|
64
|
+
...fontSize.map(fontDeclaration),
|
|
65
|
+
]
|
|
66
|
+
return content.join('\n')
|
|
67
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { SubtitleStyle } from './types'
|
|
2
|
+
|
|
3
|
+
import { assign, formatColor, hexColorToRGB } from './util'
|
|
4
|
+
|
|
5
|
+
// const builtinRules = {
|
|
6
|
+
// COLOR: true,
|
|
7
|
+
// TOP: true,
|
|
8
|
+
// BOTTOM: true
|
|
9
|
+
// }
|
|
10
|
+
|
|
11
|
+
// const convertBlockRule = (rule: string) =>
|
|
12
|
+
// builtinRules[rule] ? rule : new RegExp(rule)
|
|
13
|
+
|
|
14
|
+
export default (overrides = {}): SubtitleStyle => {
|
|
15
|
+
const defaults = {
|
|
16
|
+
fontSize: [25, 25, 36],
|
|
17
|
+
fontName: 'SimHei',
|
|
18
|
+
color: '#ffffff',
|
|
19
|
+
outlineColor: undefined,
|
|
20
|
+
backColor: undefined,
|
|
21
|
+
outline: 2,
|
|
22
|
+
shadow: 0,
|
|
23
|
+
bold: false,
|
|
24
|
+
padding: [2, 2, 2, 2],
|
|
25
|
+
playResX: 1280,
|
|
26
|
+
playResY: 720,
|
|
27
|
+
scrollTime: 8,
|
|
28
|
+
fixTime: 4,
|
|
29
|
+
opacity: 0.6,
|
|
30
|
+
bottomSpace: 60,
|
|
31
|
+
includeRaw: true,
|
|
32
|
+
mergeIn: -1,
|
|
33
|
+
// block: [],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const config = assign(defaults, overrides)
|
|
37
|
+
config.color = formatColor(hexColorToRGB(config.color))
|
|
38
|
+
config.outlineColor =
|
|
39
|
+
config.outlineColor && formatColor(hexColorToRGB(config.outlineColor))
|
|
40
|
+
config.backColor =
|
|
41
|
+
config.backColor && formatColor(hexColorToRGB(config.backColor))
|
|
42
|
+
// config.block = uniqueArray(config.block).map(convertBlockRule)
|
|
43
|
+
|
|
44
|
+
return config
|
|
45
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// import parse from './parse/bilibili'
|
|
2
|
+
import type { SubtitleStyle } from './types'
|
|
3
|
+
|
|
4
|
+
import { UniPool } from '..'
|
|
5
|
+
import ass from './ass/create'
|
|
6
|
+
import { deRaw } from './ass/raw'
|
|
7
|
+
import getConfig from './config'
|
|
8
|
+
import { DanmakuList2UniPool, layoutDanmaku } from './util'
|
|
9
|
+
|
|
10
|
+
export type Options = {
|
|
11
|
+
filename?: string
|
|
12
|
+
title?: string
|
|
13
|
+
substyle?: Partial<SubtitleStyle>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 使用bilibili弹幕(XMl)生成ASS字幕文件
|
|
18
|
+
* @param {string} danmaku XML弹幕文件内容
|
|
19
|
+
* @param {Options} options 杂项
|
|
20
|
+
* @returns {string} 返回ASS字幕文件内容
|
|
21
|
+
* @description 杂项相关
|
|
22
|
+
`filename`: 还原文件为XML时使用的默认文件名
|
|
23
|
+
`title`: ASS [Script Info] Title 项的值,显示于播放器字幕选择
|
|
24
|
+
`substyle`: ASS字幕样式
|
|
25
|
+
* @example ```ts
|
|
26
|
+
import fs from 'fs'
|
|
27
|
+
const filename = 'example.xml'
|
|
28
|
+
const xmlText = fs.readFileSync(filename, 'utf-8')
|
|
29
|
+
const assText = generateASS(xmlText, { filename, title: 'Quick Example' })
|
|
30
|
+
fs.writeFileSync(`${filename}.ass`, assText, 'utf-8')
|
|
31
|
+
```
|
|
32
|
+
*/
|
|
33
|
+
export function generateASS(danmaku: UniPool, options: Options): string {
|
|
34
|
+
// const result = parse(text)
|
|
35
|
+
const config = getConfig(options.substyle)
|
|
36
|
+
// const filteredList = filterDanmaku(result.list, config.block)
|
|
37
|
+
// const mergedList = mergeDanmaku(result.list, config.mergeIn)
|
|
38
|
+
const mergedList = danmaku.merge(config.mergeIn)
|
|
39
|
+
const layoutList = layoutDanmaku(mergedList, config)
|
|
40
|
+
const content = ass(layoutList, danmaku, config, {
|
|
41
|
+
filename: options?.filename || 'unknown',
|
|
42
|
+
title: options?.title || 'unknown',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return content
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseAssRawField(ass: string): UniPool {
|
|
49
|
+
const raw = deRaw(ass)
|
|
50
|
+
if (!raw) return UniPool.create()
|
|
51
|
+
else return DanmakuList2UniPool(raw.list)
|
|
52
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// export type BlockRule = string | RegExp
|
|
2
|
+
|
|
3
|
+
import type { UniDM } from '../utils/dm-gen'
|
|
4
|
+
|
|
5
|
+
export interface Context {
|
|
6
|
+
filename: string
|
|
7
|
+
title: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SubtitleStyle {
|
|
11
|
+
fontSize: number[]
|
|
12
|
+
fontName: string
|
|
13
|
+
color: string
|
|
14
|
+
outlineColor?: string
|
|
15
|
+
backColor?: string
|
|
16
|
+
outline: number
|
|
17
|
+
shadow: number
|
|
18
|
+
bold: boolean
|
|
19
|
+
padding: number[]
|
|
20
|
+
playResX: number
|
|
21
|
+
playResY: number
|
|
22
|
+
scrollTime: number
|
|
23
|
+
fixTime: number
|
|
24
|
+
opacity: number
|
|
25
|
+
bottomSpace: number
|
|
26
|
+
// block: BlockRule[]
|
|
27
|
+
includeRaw: boolean
|
|
28
|
+
mergeIn: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type RGB = { r: number; g: number; b: number }
|
|
32
|
+
|
|
33
|
+
export const DanmakuType = {
|
|
34
|
+
SCROLL: 1,
|
|
35
|
+
BOTTOM: 2,
|
|
36
|
+
TOP: 3,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const FontSize = {
|
|
40
|
+
SMALL: 0,
|
|
41
|
+
NORMAL: 1,
|
|
42
|
+
LARGE: 2,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type Danmaku = {
|
|
46
|
+
time: number
|
|
47
|
+
type: number
|
|
48
|
+
fontSizeType: number
|
|
49
|
+
color: RGB
|
|
50
|
+
content: string
|
|
51
|
+
extra: UniDM
|
|
52
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { RGB } from '../types'
|
|
2
|
+
|
|
3
|
+
const pad = (s: string) => (s.length < 2 ? `0${s}` : s)
|
|
4
|
+
const decimalToHex = (n: number) => pad(n.toString(16))
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 本函数实现复制自 [us-danmaku](https://github.com/tiansh/us-danmaku) 项目
|
|
8
|
+
*/
|
|
9
|
+
const isDarkColor = ({ r, g, b }: RGB) =>
|
|
10
|
+
r * 0.299 + g * 0.587 + b * 0.114 < 0x30
|
|
11
|
+
|
|
12
|
+
const WHITE = { r: 255, g: 255, b: 255 }
|
|
13
|
+
const BLACK = { r: 0, g: 0, b: 0 }
|
|
14
|
+
|
|
15
|
+
export const hexColorToRGB = (hex: string) => {
|
|
16
|
+
if (hex.indexOf('#') === 0) {
|
|
17
|
+
hex = hex.slice(1)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const [r, g, b] =
|
|
21
|
+
hex.length === 3 ? hex.split('').map((c) => c + c) : hex.match(/../g) || []
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
r: Number.parseInt(r, 16),
|
|
25
|
+
g: Number.parseInt(g, 16),
|
|
26
|
+
b: Number.parseInt(b, 16),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const decimalColorToRGB = (decimal: number) => {
|
|
31
|
+
const div = (i: number, j: number) => Math.floor(i / j)
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
r: div(decimal, 256 * 256),
|
|
35
|
+
g: div(decimal, 256) % 256,
|
|
36
|
+
b: decimal % 256,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const formatColor = ({ r, g, b }: RGB, opacity?: number) => {
|
|
41
|
+
const color = [b, g, r]
|
|
42
|
+
|
|
43
|
+
if (opacity !== undefined) {
|
|
44
|
+
const alpha = Math.round((1 - opacity) * 255)
|
|
45
|
+
color.unshift(alpha)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return `&H${color.map(decimalToHex).join('').toUpperCase()}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const getDecoratingColor = (color: RGB) =>
|
|
52
|
+
isDarkColor(color) ? WHITE : BLACK
|
|
53
|
+
|
|
54
|
+
export const isWhite = (color: RGB) =>
|
|
55
|
+
color.r === 255 && color.g === 255 && color.b === 255
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Danmaku, RGB } from '../types'
|
|
2
|
+
|
|
3
|
+
import { UniPool } from '../..'
|
|
4
|
+
import { Modes } from '../../utils/dm-gen'
|
|
5
|
+
import { DanmakuType } from '../types'
|
|
6
|
+
|
|
7
|
+
function decimalToRGB888(decimal: number): RGB {
|
|
8
|
+
const r = (decimal >> 16) & 0xff
|
|
9
|
+
const g = (decimal >> 8) & 0xff
|
|
10
|
+
const b = decimal & 0xff
|
|
11
|
+
return {
|
|
12
|
+
r,
|
|
13
|
+
g,
|
|
14
|
+
b,
|
|
15
|
+
} satisfies RGB
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function UniPool2DanmakuLists(UP: UniPool): Danmaku[] {
|
|
19
|
+
const dans = UP.dans
|
|
20
|
+
let type = DanmakuType.SCROLL
|
|
21
|
+
return dans.map((d) => {
|
|
22
|
+
if (d.mode === Modes.Bottom) type = DanmakuType.BOTTOM
|
|
23
|
+
else if (d.mode === Modes.Top) type = DanmakuType.TOP
|
|
24
|
+
return {
|
|
25
|
+
time: d.progress,
|
|
26
|
+
type,
|
|
27
|
+
fontSizeType: d.fontsize,
|
|
28
|
+
content: d.content,
|
|
29
|
+
color: decimalToRGB888(d.color),
|
|
30
|
+
extra: d,
|
|
31
|
+
} satisfies Danmaku
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
export function DanmakuList2UniPool(d: Danmaku[]): UniPool {
|
|
35
|
+
return new UniPool(d.map((d) => d.extra))
|
|
36
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export {
|
|
2
|
+
decimalColorToRGB,
|
|
3
|
+
formatColor,
|
|
4
|
+
getDecoratingColor,
|
|
5
|
+
hexColorToRGB,
|
|
6
|
+
isWhite,
|
|
7
|
+
} from './color'
|
|
8
|
+
export { layoutDanmaku } from './layout'
|
|
9
|
+
export { arrayOfLength, assign, uniqueArray } from './lang'
|
|
10
|
+
export { DanmakuList2UniPool, UniPool2DanmakuLists } from './danconvert'
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const assign = <T extends object>(
|
|
2
|
+
source: T,
|
|
3
|
+
...targets: Partial<T>[]
|
|
4
|
+
): T => {
|
|
5
|
+
for (const target of targets) {
|
|
6
|
+
for (const key of Object.keys(target)) {
|
|
7
|
+
;(source as any)[key] = target[key as keyof typeof target]
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return source
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const arrayOfLength = <T>(length: number, defaultValue: T): T[] => {
|
|
15
|
+
// eslint-disable-next-line unicorn/no-new-array
|
|
16
|
+
const array = new Array(length)
|
|
17
|
+
for (let i = 0; i < length; i++) {
|
|
18
|
+
array[i] = defaultValue
|
|
19
|
+
}
|
|
20
|
+
return array
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const uniqueArray = <T>(array: T[]) => {
|
|
24
|
+
const duplicates: T[] = []
|
|
25
|
+
const result: T[] = []
|
|
26
|
+
|
|
27
|
+
for (const item of array) {
|
|
28
|
+
if (!duplicates.includes(item)) {
|
|
29
|
+
duplicates.push(item)
|
|
30
|
+
result.push(item)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return result
|
|
35
|
+
}
|