UNPKG

@dan-uni/dan-any

Version:

A danmaku transformer lib, supporting danmaku from different platforms.

952 lines (932 loc) 27.3 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: bigint mission: number maxlimit: number state: number real_name: number source: string d: { '#text': string '@_p': string }[] } } export interface DM_JSON_BiliUp { /** 接口状态码,0 表示成功 */ code: number /** 文本形式的状态码,约定为字符串 "0" */ message: string /** TTL(time to live) 标识,本接口常量为 1 */ ttl: number data: { /** 分页元信息 */ page: { /** 当前页序号,从 1 开始 */ num: number /** 每页返回的弹幕条数 */ size: number /** 总页数 */ total: number } result: { /** 弹幕 ID,int64 */ id: bigint /** 弹幕 ID 字符串形式 */ id_str: string /** 弹幕类型:1 表示视频弹幕(当前接口恒为 1) */ type: number aid: bigint bvid: string oid: bigint mid: bigint /** 发送者 mid 的 CRC 哈希(正常接口里用的是这个,保护隐私) */ mid_hash: string /** 弹幕池 */ pool: number /** 属性位字符串,逗号分隔的数字列表,对应 attr 二进制位 */ attrs: string /** 弹幕出现时间,单位毫秒(注意,此处与protobuf接口保持一致,但xml中progress是秒) */ progress: number mode: number /** 弹幕内容, content */ msg: string state: number // ? fontsize: number /** 弹幕颜色,需将16进制转化为普通弹幕的10进制,示例:"ffffff" */ color: string /** 发送时间戳,单位秒 */ ctime: number /** 发送者昵称 */ uname: string /** 发送者头像链接 */ uface: string /** 视频主标题 */ title: string self_seen: boolean // 尽自己可见? /** 弹幕点赞数 */ like_count: number user_like: number // ? /** 分 P 标题 */ p_title: string /** 视频封面链接 */ cover: string is_charge: boolean // 该up是否开通充电计划? is_charge_plus: boolean // 该up是否开通高级充电计划? following: boolean // 当前登录用户是否关注该发送者? extra_cps: null // ? }[] } } export interface DM_JSON_Dplayer { code: number /** * 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: bigint p: string m: string }[] } export type DM_format = | 'danuni.json' | 'danuni.pb.bin' | 'bili.xml' | 'bili.pb.bin' | 'bili.cmd.pb.bin' | 'bili.up.json' | 'dplayer.json' | 'artplayer.json' | 'ddplay.json' | 'common.ass' type shareItems = Partial< Pick< UniDMTools.UniDMObj, 'SOID' | 'senderID' | 'platform' | 'SOID' | 'pool' | 'mode' | 'color' > > type statItems = Partial< Pick< UniDMTools.UniDMObj, | 'SOID' | 'mode' | 'fontsize' | 'color' | 'senderID' | 'content' | 'weight' | 'pool' | 'platform' > > type Stats<T extends keyof statItems> = Map<statItems[T], number> type UniPoolPipe = (that: UniPool) => Promise<UniPool> type UniPoolPipeSync = (that: UniPool) => UniPool export interface Options { dedupe?: boolean /** * @description * 当为`false`时,关闭DMID生成; 当为正整数时,表示生成DMID的截取位数; 或可传入一个DMID生成器实例 */ 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) } /** * @deprecated 使用 `getShared` 代替 */ get shared(): shareItems { if (this.dans.length === 0) return {} const keys: (keyof shareItems)[] = [ 'SOID', 'senderID', 'platform', 'pool', 'mode', 'color', ] const result: shareItems = {} as shareItems for (const key of keys) { const sharedVal = this.getShared(key) if (sharedVal !== undefined) { result[key] = sharedVal as any } } return result } getShared<K extends keyof shareItems>(key: K): shareItems[K] { if (this.dans.length === 0) return undefined const firstVal = this.dans[0][key] for (let i = 1; i < this.dans.length; i++) { if (this.dans[i][key] !== firstVal) { return undefined } } return firstVal } getStat<K extends keyof statItems>(key: K): Stats<K> { const statMap = new Map<statItems[K], number>() for (const dan of this.dans) { const val = dan[key] statMap.set(val, (statMap.get(val) || 0) + 1) } return statMap } getMost<K extends keyof statItems>(key: K) { const stats = this.getStat(key) if (stats.size === 0) return { val: undefined, count: 0 } let mostVal: statItems[K] | undefined let maxCount = 0 for (const [val, count] of stats.entries()) { if (count > maxCount) { maxCount = count mostVal = val } } return { val: mostVal, count: maxCount } } /** * @deprecated 使用 `getMost` 代替 */ get most() { const keys: (keyof statItems)[] = [ 'mode', 'fontsize', 'color', 'senderID', 'content', 'weight', 'pool', 'platform', ] const statMaps = new Map< keyof statItems, Map<statItems[keyof statItems], number> >() for (const dan of this.dans) { for (const key of keys) { if (!statMaps.has(key)) { statMaps.set(key, new Map()) } const statMap = statMaps.get(key)! const val = dan[key] statMap.set(val, (statMap.get(val) || 0) + 1) } } const result: Record<string, any> = {} for (const key of keys) { const statMap = statMaps.get(key)! let mostVal: statItems[keyof statItems] | undefined let maxCount = 0 for (const [val, count] of statMap.entries()) { if (count > maxCount) { maxCount = count mostVal = val } } result[key] = mostVal } return { mode: result.mode as UniDMTools.Modes, fontsize: result.fontsize as number, color: result.color as number, senderID: result.senderID as string, content: result.content as string, weight: result.weight as number, pool: result.pool as UniDMTools.Pools, platform: result.platform as string | undefined, } } 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.getShared(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() { // 这里基本上没有性能瓶颈(大文件测试与AI优化下无明显区别) 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.getShared('SOID')) { console.error( "本功能仅支持同弹幕库内使用,可先 .split('SOID') 在分别使用", ) return this } if (lifetime <= 0) return this const result: UniDM[] = [] const cache: Record<string, UniDM> = {} const mergeObj: Record<string, UniDMTools.ExtraDanUniMerge> = {} // 第一遍:合并弹幕 for (const danmaku of this.dans) { const key = `${danmaku.content}|${danmaku.mode}|${danmaku.pool}|${danmaku.platform}` const cached = cache[key] if ( cached && danmaku.progress - cached.progress <= lifetime && danmaku.isSameAs(cached, { skipDanuniMerge: true }) ) { // 更新已存在的弹幕 mergeObj[key].senders.push(danmaku.senderID) mergeObj[key].count = mergeObj[key].senders.length mergeObj[key].taolu_count = mergeObj[key].count mergeObj[key].taolu_senders = mergeObj[key].senders mergeObj[key].duration = Number.parseFloat( (danmaku.progress - cached.progress).toFixed(3), ) cache[key] = danmaku } else { // 新弹幕 mergeObj[key] = { count: 1, duration: 0, senders: [danmaku.senderID], taolu_count: 1, taolu_senders: [danmaku.senderID], } cache[key] = danmaku result.push(danmaku) } } // 第二遍:更新 extraStr for (const danmaku of result) { const key = `${danmaku.content}|${danmaku.mode}|${danmaku.pool}|${danmaku.platform}` const mergeData = mergeObj[key] const extra = danmaku.extra if (mergeData.count > 1) { // 多个发送者:设置为机器人并添加保护标记 danmaku.senderID = 'merge[bot]@dan-any' if (!danmaku.attr) { danmaku.attr = [UniDMTools.DMAttr.Protect] } else if (!danmaku.attr.includes(UniDMTools.DMAttr.Protect)) { danmaku.attr.push(UniDMTools.DMAttr.Protect) } extra.danuni = extra.danuni || {} extra.danuni.merge = mergeData danmaku.extraStr = JSON.stringify(extra) } else { // 单个发送者:清理 merge 字段 if (extra.danuni?.merge) { delete extra.danuni.merge if (Object.keys(extra.danuni).length === 0) { delete extra.danuni } } danmaku.extraStr = Object.keys(extra).length > 0 ? JSON.stringify(extra) : undefined } } return new UniPool(result, this.options, this.info) } minify() { return this.dans.map((d) => d.minify()) } static import( file: unknown, options?: Options, /** * 加载指定解析模块,为空则全选 */ mod?: ('json' | 'str' | 'bin')[], ): { pool: UniPool; fmt: DM_format } { if (!mod) mod = ['json', 'str', 'bin'] const err = '无法识别该文件,请手动指定格式!' const parseJSON = ( json: DM_JSON_Artplayer & DM_JSON_DDPlay & DM_JSON_Dplayer & DM_JSON_BiliUp & { 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 && Array.isArray(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 && Array.isArray(json.data) && json.data.every((d) => Array.isArray(d)) ) { return { pool: this.fromDplayer( json, json.danuni?.data ?? '', undefined, options, ), fmt: 'dplayer.json', } } else if ( json.code == 0 && json.message == '0' && json.data && json.data.page && json.data.result && Array.isArray(json.data.result) && json.data.result.every((d) => d.id && d.oid) ) { return { pool: this.fromBiliUp(json, options), fmt: 'bili.up.json', } } } catch {} } const parseStr = ( file: string, ): { pool: UniPool; fmt: DM_format } | undefined => { // json-str if (mod.includes('json')) try { if (isJSON(file)) { const json = JSON.parse(file) return parseJSON(json) } } catch {} // pure-str (xml/ass) if (mod.includes('str')) { 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) { // pure-bin (pb) if (mod.includes('bin')) { 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 {} } // str-bin (pure-str + json-str) try { const fileStr = new TextDecoder().decode(file) const prStr = parseStr(fileStr) if (prStr) { return prStr } else { errmesg = `${err}(定位: bin->string)` } } catch {} } else if (mod.includes('json')) { // pure-json const prJSON = parseJSON(file as any) if (!prJSON) throw new Error(`${err}(定位: json)`) return prJSON } } else if (isString(file)) { // pure-str + json-str 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 }) const oriData: DM_XML_Bili & { i: { danuni?: DanUniConvertTip } } = parser.parse(xml) const dans = oriData.i.d const fromConverted = !!oriData.i.danuni const cid = BigInt(oriData.i.chatid) return new UniPool( dans .map((d) => { return UniDM.fromBili( UniDM.parseBiliSingle(d['@_p'], d['#text']), cid, options, fromConverted ? oriData.i.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', // eslint-disable-next-line unicorn/text-encoding-identifier-case '@_encoding': 'UTF-8', }, 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', danuni: { ...DanUniConvertTipTemplate, data: this.getShared('SOID') }, d: this.dans.map((dan) => dan.toBiliXML(options)), }, }) } static fromBiliGrpc(bin: Uint8Array | ArrayBuffer, options?: Options) { const data = fromBinary(DmSegMobileReplySchema, new Uint8Array(bin)) const 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)) const json = data.commandDms return new UniPool( json.map((d) => { return UniDM.fromBiliCommand(d, d.oid, options) }), options, ) } static fromBiliUp(json: DM_JSON_BiliUp, options?: Options) { return new UniPool( json.data.result.map((d) => { // 处理 attrs 字符串转换为 attr 二进制 // attrs 格式如 "1,13,21",每个数字对应二进制位 const attrBin = d.attrs ? d.attrs .split(',') .map(Number) .reduce((bin, bitPosition) => bin | (1 << (bitPosition - 1)), 0) : 0 return UniDM.fromBili( { id: BigInt(d.id_str || d.id), progress: d.progress / 1000, // 毫秒转秒 mode: d.mode, fontsize: d.fontsize, color: Number.parseInt(d.color, 16), mid: d.mid, midHash: d.mid_hash, content: d.msg, ctime: BigInt(d.ctime), pool: d.pool, // idStr: d.id_str, attr: attrBin, oid: BigInt(d.oid), }, BigInt(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): string { const defaultOptions: AssGenOptions = { substyle: {} } const finalOptions = options ?? defaultOptions const fn = this.getShared('SOID') return generateASS( this, { filename: fn, title: fn, ...finalOptions }, canvasCtx, ) } } export { platform, // UniPool, UniDM, UniDMTools, UniIDTools, type DM_JSON_BiliCommandGrpc, // type UniDMType, // type UniIDType, }