UNPKG

@dan-uni/dan-any

Version:

A danmaku transformer lib, supporting danmaku from different platforms.

837 lines (821 loc) 24.7 kB
import 'reflect-metadata/lite' import { isJSON, isObject, isString } from 'class-validator' import { XMLBuilder, XMLParser } from 'fast-xml-parser' import JSONbig from 'json-bigint' import type { Options as AssGenOptions, CanvasCtx } from './ass-gen' import type { CommandDm as DM_JSON_BiliCommandGrpc } from './proto/gen/bili/dm_pb' import { create, fromBinary, toBinary } from '@bufbuild/protobuf' import { timestampDate, timestampFromDate, timestampNow, } from '@bufbuild/protobuf/wkt' import pkg from '../package.json' import { generateASS, parseAssRawField } from './ass-gen' import { // DanmakuElem as DM_JSON_BiliGrpc, DmSegMobileReplySchema, DmWebViewReplySchema, } from './proto/gen/bili/dm_pb' import { DanmakuReplySchema } from './proto/gen/danuni_pb' // import type * as UniIDType from './utils/id-gen' import { UniDM } from './utils/dm-gen' import * as UniDMTools from './utils/dm-gen' import { UniID as ID } from './utils/id-gen' import * as UniIDTools from './utils/id-gen' import * as platform from './utils/platform' const JSON = JSONbig({ useNativeBigInt: true, }) const DanUniConvertTipTemplate: DanUniConvertTip = { meassage: 'Converted by DanUni!', version: `JS/TS ${pkg.name} (v${pkg.version})`, } interface DanUniConvertTip { meassage: string version: string data?: string } export interface DM_XML_Bili { i: { chatserver: string chatid: number mission: number maxlimit: number state: number real_name: number source: string d: { '#text': string '@_p': string }[] } } export interface DM_JSON_Dplayer { code: 0 /** * progress,mode,color,midHash,content */ data: [number, number, number, string, string][] } export interface DM_JSON_Artplayer { danmuku: { text: string // 弹幕文本 time?: number // 弹幕时间,默认为当前播放器时间 mode?: number // 弹幕模式:0: 滚动 (默认),1: 顶部,2: 底部 color?: string // 弹幕颜色,默认为白色 border?: boolean // 弹幕是否有描边,默认为 false style?: {} // 弹幕自定义样式,默认为空对象 }[] } export interface DM_JSON_DDPlay { count: number | string comments: { cid: number p: string m: string }[] } export type DM_format = | 'danuni.json' | 'danuni.pb.bin' | 'bili.xml' | 'bili.pb.bin' | 'bili.cmd.pb.bin' | 'dplayer.json' | 'artplayer.json' | 'ddplay.json' | 'common.ass' type shareItems = Partial< Pick< UniDMTools.UniDMObj, 'SOID' | 'senderID' | 'platform' | 'SOID' | 'pool' | 'mode' | 'color' > > type UniPoolPipe = (that: UniPool) => Promise<UniPool> type UniPoolPipeSync = (that: UniPool) => UniPool export interface Options { dedupe?: boolean dmid?: boolean | number | UniIDTools.DMIDGenerator } export class UniPool { constructor( public dans: UniDM[], public options: Options = {}, public info = { /** * 是否从已被转换过的第三方格式弹幕再次转换而来 */ fromConverted: false, }, ) { if (options.dedupe !== false) options.dedupe = true if (this.options.dedupe) this.dedupe() } async pipe(fn: UniPoolPipe): Promise<UniPool> { return fn(this) } pipeSync(fn: UniPoolPipeSync): UniPool { return fn(this) } get shared(): shareItems { const isShared = (key: keyof UniDMTools.UniDMObj) => { return this.dans.every((d) => d[key]) } return { SOID: isShared('SOID') ? this.dans[0].SOID : undefined, senderID: isShared('senderID') ? this.dans[0].senderID : undefined, platform: isShared('platform') ? this.dans[0].platform : undefined, pool: isShared('pool') ? this.dans[0].pool : undefined, mode: isShared('mode') ? this.dans[0].mode : undefined, color: isShared('color') ? this.dans[0].color : undefined, } } get stat() { const default_stat = { SOID: [] as { val: string; count: number }[], mode: [ { val: UniDMTools.Modes.Normal, count: 0 }, { val: UniDMTools.Modes.Bottom, count: 0 }, { val: UniDMTools.Modes.Top, count: 0 }, { val: UniDMTools.Modes.Reverse, count: 0 }, { val: UniDMTools.Modes.Ext, count: 0 }, ], fontsize: [ // { val: 18, count: 0 }, // { val: 25, count: 0 }, // { val: 36, count: 0 }, ] as { val: number; count: number }[], color: [] as { val: number; count: number }[], senderID: [] as { val: string; count: number }[], content: [] as { val: string; count: number }[], weight: [] as { val: number; count: number }[], pool: [ { val: UniDMTools.Pools.Def, count: 0 }, { val: UniDMTools.Pools.Sub, count: 0 }, { val: UniDMTools.Pools.Adv, count: 0 }, { val: UniDMTools.Pools.Ix, count: 0 }, ], platform: [] as { val?: string; count: number }[], } type Stat = typeof default_stat const stat = this.dans.reduce((s, d): Stat => { const SOID = s.SOID.find((i) => i.val === d.SOID) if (!SOID) { s.SOID.push({ val: d.SOID, count: 1 }) } else { SOID.count++ } const mode = s.mode.find((i) => i.val === d.mode) if (!mode) { s.mode.push({ val: d.mode, count: 1 }) } else { mode.count++ } const fontsize = s.fontsize.find((i) => i.val === d.fontsize) if (!fontsize) { s.fontsize.push({ val: d.fontsize, count: 1 }) } else { fontsize.count++ } const color = s.color.find((i) => i.val === d.color) if (!color) { s.color.push({ val: d.color, count: 1 }) } else { color.count++ } const senderID = s.senderID.find((i) => i.val === d.senderID) if (!senderID) { s.senderID.push({ val: d.senderID, count: 1 }) } else { senderID.count++ } const content = s.content.find((i) => i.val === d.content) if (!content) { s.content.push({ val: d.content, count: 1 }) } else { content.count++ } const weight = s.weight.find((i) => i.val === d.weight) if (!weight) { s.weight.push({ val: d.weight, count: 1 }) } else { weight.count++ } const pool = s.pool.find((i) => i.val === d.pool) if (!pool) { s.pool.push({ val: d.pool, count: 1 }) } else { pool.count++ } const platform = s.platform.find((i) => i.val === d.platform) if (!platform) { s.platform.push({ val: d.platform, count: 1 }) } else { platform.count++ } return s }, default_stat) return stat } get most() { const s = this.stat return { mode: s.mode.sort((a, b) => b.count - a.count)[0].val, fontsize: s.fontsize.sort((a, b) => b.count - a.count)[0].val, color: s.color.sort((a, b) => b.count - a.count)[0].val, senderID: s.senderID.sort((a, b) => b.count - a.count)[0].val, content: s.content.sort((a, b) => b.count - a.count)[0].val, weight: s.weight.sort((a, b) => b.count - a.count)[0].val, pool: s.pool.sort((a, b) => b.count - a.count)[0].val, platform: s.platform.sort((a, b) => b.count - a.count)[0].val, } } static create(options?: Options) { return new UniPool([], options) } /** * 合并弹幕/弹幕库 */ assign(dans: UniPool | UniDM | UniDM[]) { if (dans instanceof UniPool) { return new UniPool( [...this.dans, ...dans.dans], { ...this.options, ...dans.options }, { ...this.info, ...dans.info }, ) } else if (dans instanceof UniDM) { return new UniPool([...this.dans, dans], this.options, this.info) } else if (Array.isArray(dans) && dans.every((d) => d instanceof UniDM)) { return new UniPool([...this.dans, ...dans], this.options, this.info) } else return this } /** * 按共通属性拆分弹幕库 */ split(key: keyof shareItems) { if (this.shared[key]) return [this] const set = new Set(this.dans.map((d) => d[key])) return [...set].map((v) => { return new UniPool( this.dans.filter((d) => d[key] === v), { ...this.options, dedupe: false }, this.info, ) }) } /** * 基于DMID的基本去重功能,用于解决该class下dans为array而非Set的问题 */ private dedupe() { if (this.options.dmid !== false) { const map = new Map() this.dans.forEach((d) => map.set(d.DMID || d.toDMID(), d)) this.dans = [...map.values()] } this.options.dedupe = false } /** * 合并一定时间段内的重复弹幕,防止同屏出现过多 * @param lifetime 查重时间区段,单位秒 (默认为 0,表示不查重) */ merge(lifetime = 0) { if (!this.shared.SOID) { console.error( "本功能仅支持同弹幕库内使用,可先 .split('SOID') 在分别使用", ) return this } if (lifetime <= 0) return this const mergeContext = this.dans.reduce< [ UniDM[], Record<string, UniDM>, Record<string, UniDMTools.ExtraDanUniMerge>, ] >( ([result, cache, mergeObj], danmaku) => { const key = ['content', 'mode', 'pool', 'platform'] .map((k) => danmaku[k as keyof UniDM]) .join('|') const cached = cache[key] const lastAppearTime = cached?.progress || 0 if ( cached && danmaku.progress - lastAppearTime <= lifetime && danmaku.isSameAs(cached, { skipDanuniMerge: true }) ) { const senders = mergeObj[key].senders senders.push(danmaku.senderID) const extra = danmaku.extra extra.danuni = extra.danuni || {} extra.danuni.merge = { count: senders.length, duration: Number.parseFloat( (danmaku.progress - cached.progress).toFixed(3), ), senders, taolu_count: senders.length, taolu_senders: senders, } danmaku.extraStr = JSON.stringify(extra) cache[key] = danmaku mergeObj[key] = extra.danuni.merge return [result, cache, mergeObj] } else { mergeObj[key] = { count: 1, duration: 0, senders: [danmaku.senderID], taolu_count: 1, taolu_senders: [danmaku.senderID], } cache[key] = danmaku // 初始化merge信息,包含第一个sender const extra = danmaku.extra extra.danuni = extra.danuni || {} extra.danuni.merge = mergeObj[key] danmaku.extraStr = JSON.stringify(extra) result.push(danmaku) return [result, cache, mergeObj] } }, [[], {}, {}], ) // 处理结果,删除senders<=1的merge字段 const [result, _cache, mergeObj] = mergeContext result.forEach((danmaku, i) => { const key = ['content', 'mode', 'platform', 'pool'] .map((k) => danmaku[k as keyof UniDM]) .join('|') const extra = result[i].extra, mergeData = mergeObj[key] result[i].extraStr = JSON.stringify({ ...extra, danuni: { ...extra.danuni, merge: mergeData, }, } satisfies UniDMTools.Extra) if (mergeData?.count) { if (mergeData.count <= 1) { const updatedExtra = { ...extra } if (updatedExtra.danuni) { delete updatedExtra.danuni.merge if (Object.keys(updatedExtra.danuni).length === 0) { delete updatedExtra.danuni } } result[i].extraStr = Object.keys(updatedExtra).length > 0 ? JSON.stringify(updatedExtra) : undefined } else { result[i].senderID = 'merge[bot]@dan-any' result[i].attr ? result[i].attr.push(UniDMTools.DMAttr.Protect) : (result[i].attr = [UniDMTools.DMAttr.Protect]) } } }) return new UniPool(result, this.options, this.info) } minify() { return this.dans.map((d) => d.minify()) } static import( file: unknown, options?: Options, ): { pool: UniPool; fmt: DM_format } { const err = '无法识别该文件,请手动指定格式!' const parseJSON = ( json: DM_JSON_Artplayer & DM_JSON_DDPlay & DM_JSON_Dplayer & { danuni?: DanUniConvertTip }, ): { pool: UniPool; fmt: DM_format } | undefined => { try { if (Array.isArray(json) && json.every((d) => d.SOID)) { return { pool: new UniPool(json, options), fmt: 'danuni.json' } } else if (json.danmuku && json.danmuku.every((d) => d.text)) { return { pool: this.fromArtplayer( json, json.danuni?.data ?? '', undefined, options, ), fmt: 'artplayer.json', } } else if ( json.count && json.comments && json.comments.every((d) => d.m) ) { return { pool: this.fromDDPlay(json, json.danuni?.data ?? '', options), fmt: 'ddplay.json', } } else if ( json?.code == 0 && json.data && json.data.every((d) => Array.isArray(d)) ) { return { pool: this.fromDplayer( json, json.danuni?.data ?? '', undefined, options, ), fmt: 'dplayer.json', } } } catch {} } const parseStr = ( file: string, ): { pool: UniPool; fmt: DM_format } | undefined => { try { if (isJSON(file)) { const json = JSON.parse(file) return parseJSON(json) } } catch {} try { const xmlParser = new XMLParser({ ignoreAttributes: false }) const xml = xmlParser.parse(file) if (xml?.i?.d) return { pool: this.fromBiliXML(file, options), fmt: 'bili.xml' } } catch {} try { return { pool: this.fromASS(file, options), fmt: 'common.ass' } } catch {} } let errmesg if (isObject(file)) { if (file instanceof ArrayBuffer || file instanceof Uint8Array) { try { return { pool: this.fromPb(file), fmt: 'danuni.pb.bin' } } catch {} try { return { pool: this.fromBiliGrpc(file), fmt: 'bili.pb.bin' } } catch {} try { return { pool: this.fromBiliCommandGrpc(file), fmt: 'bili.cmd.pb.bin', } } catch {} try { const fileStr = new TextDecoder().decode(file) const prStr = parseStr(fileStr) if (!prStr) errmesg = `${err}(定位: bin->string)` else return prStr } catch {} } else { const prJSON = parseJSON(file as any) if (!prJSON) throw new Error(`${err}(定位: json)`) return prJSON } } else if (isString(file)) { const prStr = parseStr(file) if (!prStr) throw new Error(`${err}(定位: string)`) return prStr } throw new Error(errmesg ?? err) } convert2(format: DM_format, continue_on_error = false) { switch (format) { case 'danuni.json': return this.dans case 'danuni.pb.bin': return this.toPb() case 'bili.xml': return this.toBiliXML() // case 'bili.bin': // return this.toBiliBin() // case 'bili.cmd.bin': // return this.toBiliCmdBin() case 'dplayer.json': return this.toDplayer() case 'artplayer.json': return this.toArtplayer() case 'ddplay.json': return this.toDDplay() // case 'common.ass': // return this.toASS() default: { const message = '(err) Unknown format or unsupported now!' if (continue_on_error) return message else throw new Error(message) } } } static fromPb(bin: Uint8Array | ArrayBuffer, options?: Options) { const data = fromBinary(DanmakuReplySchema, new Uint8Array(bin)) return new UniPool( data.danmakus.map((d) => UniDM.create( { ...d, progress: d.progress / 1000, mode: d.mode as number, ctime: timestampDate(d.ctime || timestampNow()), pool: d.pool as number, attr: d.attr as UniDMTools.DMAttr[], extra: undefined, extraStr: d.extra, }, options, ), ), options, ) } /** * 转为 protobuf 二进制 */ toPb() { return toBinary( DanmakuReplySchema, create(DanmakuReplySchema, { danmakus: this.dans.map((d) => { return { SOID: d.SOID, DMID: d.DMID, progress: Math.round(d.progress * 1000), mode: d.mode as number, fontsize: d.fontsize, color: d.color, senderID: d.senderID, content: d.content, ctime: timestampFromDate(d.ctime), weight: d.weight, pool: d.pool as number, attr: d.attr, platform: d.platform, extra: d.extraStr, } }), }), ) } static fromBiliXML(xml: string, options?: Options) { const parser = new XMLParser({ ignoreAttributes: false }), oriData: DM_XML_Bili & { danuni?: DanUniConvertTip } = parser.parse(xml), dans = oriData.i.d, fromConverted = !!oriData.danuni, cid = BigInt(oriData.i.chatid) return new UniPool( dans .map((d) => { const p_str = d['@_p'], p_arr = p_str.split(',') return UniDM.fromBili( { content: d['#text'], progress: Number.parseFloat(p_arr[0]), mode: Number.parseInt(p_arr[1]), fontsize: Number.parseInt(p_arr[2]), color: Number.parseInt(p_arr[3]), ctime: BigInt(p_arr[4]), pool: Number.parseInt(p_arr[5]), midHash: p_arr[6], id: BigInt(p_arr[7]), weight: Number.parseInt(p_arr[8]), }, cid, options, fromConverted ? oriData.danuni?.data : undefined, ) }) .filter((d) => d !== null), options, { fromConverted }, ) } toBiliXML(options?: { /** * 当SOID非来源bili时,若此处指定则使用该值为cid,否则使用SOID */ cid?: bigint /** * 当仅含有来自bili的弹幕时,启用将保持发送者标识不含`@` * @description * bili的弹幕含midHash(crc),不启用该处使用senderID填充,启用则去除`@bili`部分,提高兼容性 */ avoidSenderIDWithAt?: boolean }): string { const genCID = (id: string) => { const UniID = ID.fromString(id) if (UniID.domain === platform.PlatformVideoSource.Bilibili) { const cid = UniID.id.replaceAll( `def_${platform.PlatformVideoSource.Bilibili}+`, '', ) if (cid) return cid } return !options?.cid || id } if (options?.avoidSenderIDWithAt) { const ok = this.dans.every((d) => d.senderID.endsWith(`@${platform.PlatformVideoSource.Bilibili}`), ) if (!ok) throw new Error('存在其他来源的senderID,请关闭该功能再试!') } const builder = new XMLBuilder({ ignoreAttributes: false }) return builder.build({ '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8', }, danuni: { ...DanUniConvertTipTemplate, data: this.shared.SOID }, i: { chatserver: 'chat.bilibili.com', chatid: genCID(this.dans[0].SOID), mission: 0, maxlimit: this.dans.length, state: 0, real_name: 0, source: 'k-v', d: this.dans.map((dan) => dan.toBiliXML(options)), }, }) } static fromBiliGrpc(bin: Uint8Array | ArrayBuffer, options?: Options) { const data = fromBinary(DmSegMobileReplySchema, new Uint8Array(bin)), json = data.elems return new UniPool( json.map((d) => { return UniDM.fromBili( { ...d, progress: d.progress / 1000 }, d.oid, options, ) }), options, ) } /** * @param bin 符合`DmWebViewReplySchema`(bili视频meta)的protobuf二进制 */ static fromBiliCommandGrpc(bin: Uint8Array | ArrayBuffer, options?: Options) { const data = fromBinary(DmWebViewReplySchema, new Uint8Array(bin)), json = data.commandDms return new UniPool( json.map((d) => { return UniDM.fromBiliCommand(d, d.oid, options) }), options, ) } static fromDplayer( json: DM_JSON_Dplayer & { danuni?: DanUniConvertTip }, playerID: string, domain = 'other', options?: Options, ) { return new UniPool( json.data.map((d) => { return UniDM.fromDplayer( { content: d[4], progress: d[0], mode: d[1], color: d[2], midHash: d[3], }, playerID, domain, options, ) }), options, { fromConverted: !!json.danuni }, ) } toDplayer(): DM_JSON_Dplayer & { danuni?: DanUniConvertTip } { return { code: 0, danuni: { ...DanUniConvertTipTemplate, data: this.dans[0].SOID.split('@')[0], }, data: this.dans.map((dan) => { const d = dan.toDplayer() return [d.progress, d.mode, d.color, d.midHash, d.content] }), } } static fromArtplayer( json: DM_JSON_Artplayer & { danuni?: DanUniConvertTip }, playerID: string, domain = 'other', options?: Options, ) { return new UniPool( json.danmuku.map((d) => { return UniDM.fromArtplayer( { content: d.text, progress: d.time || 0, mode: d.mode || 0, color: Number((d.color || 'FFFFFF').replace('#', '0x')), style: d.style, }, playerID, domain, options, ) }), options, { fromConverted: !!json.danuni }, ) } toArtplayer(): DM_JSON_Artplayer & { danuni?: DanUniConvertTip } { return { danuni: { ...DanUniConvertTipTemplate, data: this.dans[0].SOID.split('@')[0], }, danmuku: this.dans.map((dan) => { const d = dan.toArtplayer() return { text: d.content, time: d.progress, mode: d.mode as 0 | 1 | 2, color: `#${d.color.toString(16).toUpperCase() || 'FFFFFF'}`, border: d.border, style: d.style, } }), } } static fromDDPlay( json: DM_JSON_DDPlay & { danuni?: DanUniConvertTip }, episodeId: string, options?: Options, ) { return new UniPool( json.comments.map((d) => { const p_arr = d.p.split(',') return UniDM.fromDDplay( { cid: d.cid, color: Number.parseInt(p_arr[2]), m: d.m, mode: Number.parseInt(p_arr[1]), progress: Number.parseFloat(p_arr[0]), uid: p_arr[3], }, episodeId, undefined, //使用默认 options, ) }), options, { fromConverted: !!json.danuni }, ) } toDDplay(): DM_JSON_DDPlay & { danuni?: DanUniConvertTip } { const episodeId = this.dans[0].SOID.split('@')[0].replaceAll( `def_${platform.PlatformDanmakuOnlySource.DanDanPlay}+`, '', ) return { danuni: { ...DanUniConvertTipTemplate, data: episodeId }, count: this.dans.length, comments: this.dans.map((dan) => { const d = dan.toDDplay() return { cid: d.cid, p: `${d.progress},${d.mode},${d.color},${d.uid}`, m: d.m, } }), } } static fromASS(ass: string, options?: Options) { return parseAssRawField(ass, options) } /** * 转换为ASS字幕格式的弹幕,需播放器支持多行ASS渲染 */ toASS( canvasCtx: CanvasCtx, options: AssGenOptions = { substyle: {} }, ): string { const fn = this.shared.SOID return generateASS(this, { filename: fn, title: fn, ...options }, canvasCtx) } } export { platform, // UniPool, UniDM, UniDMTools, UniIDTools, type DM_JSON_BiliCommandGrpc, // type UniDMType, // type UniIDType, }