Skip to content

Instantly share code, notes, and snippets.

@zwh8800
Last active February 12, 2020 14:45
Show Gist options
  • Save zwh8800/942d6a276f780498c16ee7d88a08d4b7 to your computer and use it in GitHub Desktop.
Save zwh8800/942d6a276f780498c16ee7d88a08d4b7 to your computer and use it in GitHub Desktop.
转换b站弹幕为ass字幕-quickjs/nodejs版

转换b站弹幕为ass字幕-quickjs/nodejs版

使用方法

nodejs 版

下载 bili-conv.js 到本地,需安装依赖xml2js

npm i xml2js

之后执行

node bili-conv.js av123456.cmt.xml > av123456.ass

quickjs 版

下载 bili-conv-qjs.js 到本地

之后执行

qjs bili-conv-qjs.js av123456.cmt.xml > av123456.ass

也可先编译,后执行

qjsc -o bili-conv-qjs bili-conv-qjs.js
./bili-conv-qjs av123456.cmt.xml > av123456.ass

多文件合并

支持将多个弹幕文件合并为一个字幕文件,字幕时间按文件先后顺序排列

node bili-conv.js av1.cmt.xml av2.cmt.xml av3.cmt.xml > av123.ass
import * as std from 'std';
import * as os from 'os';
// ==ClosureCompiler==
// @output_file_name default.js
// @compilation_level SIMPLE_OPTIMIZATIONS
// ==/ClosureCompiler==
/**
* @author: Tobias Nickel
* @created: 06.04.2015
* I needed a small xmlparser chat can be used in a worker.
*/
/**
* @typedef tNode
* @property {string} tagName
* @property {object} [attributes]
* @property {tNode|string|number[]} children
**/
/**
* parseXML / html into a DOM Object. with no validation and some failur tolerance
* @param {string} S your XML to parse
* @param options {object} all other options:
* searchId {string} the id of a single element, that should be returned. using this will increase the speed rapidly
* filter {function} filter method, as you know it from Array.filter. but is goes throw the DOM.
* @return {tNode[]}
*/
function tXml(S, options) {
"use strict";
options = options || {};
var pos = options.pos || 0;
var openBracket = "<";
var openBracketCC = "<".charCodeAt(0);
var closeBracket = ">";
var closeBracketCC = ">".charCodeAt(0);
var minus = "-";
var minusCC = "-".charCodeAt(0);
var slash = "/";
var slashCC = "/".charCodeAt(0);
var exclamation = '!';
var exclamationCC = '!'.charCodeAt(0);
var singleQuote = "'";
var singleQuoteCC = "'".charCodeAt(0);
var doubleQuote = '"';
var doubleQuoteCC = '"'.charCodeAt(0);
/**
* parsing a list of entries
*/
function parseChildren() {
var children = [];
while (S[pos]) {
if (S.charCodeAt(pos) == openBracketCC) {
if (S.charCodeAt(pos + 1) === slashCC) {
pos = S.indexOf(closeBracket, pos);
if (pos + 1) pos += 1
return children;
} else if (S.charCodeAt(pos + 1) === exclamationCC) {
if (S.charCodeAt(pos + 2) == minusCC) {
//comment support
while (pos !== -1 && !(S.charCodeAt(pos) === closeBracketCC && S.charCodeAt(pos - 1) == minusCC && S.charCodeAt(pos - 2) == minusCC && pos != -1)) {
pos = S.indexOf(closeBracket, pos + 1);
}
if (pos === -1) {
pos = S.length
}
} else {
// doctypesupport
pos += 2;
while (S.charCodeAt(pos) !== closeBracketCC && S[pos]) {
pos++;
}
}
pos++;
continue;
}
var node = parseNode();
children.push(node);
} else {
var text = parseText()
if (text.trim().length > 0)
children.push(text);
pos++;
}
}
return children;
}
/**
* returns the text outside of texts until the first '<'
*/
function parseText() {
var start = pos;
pos = S.indexOf(openBracket, pos) - 1;
if (pos === -2)
pos = S.length;
return S.slice(start, pos + 1);
}
/**
* returns text until the first nonAlphebetic letter
*/
var nameSpacer = '\n\t>/= ';
function parseName() {
var start = pos;
while (nameSpacer.indexOf(S[pos]) === -1 && S[pos]) {
pos++;
}
return S.slice(start, pos);
}
/**
* is parsing a node, including tagName, Attributes and its children,
* to parse children it uses the parseChildren again, that makes the parsing recursive
*/
var NoChildNodes = options.noChildNodes || ['img', 'br', 'input', 'meta', 'link'];
function parseNode() {
pos++;
const tagName = parseName();
const attributes = {};
let children = [];
// parsing attributes
while (S.charCodeAt(pos) !== closeBracketCC && S[pos]) {
var c = S.charCodeAt(pos);
if ((c > 64 && c < 91) || (c > 96 && c < 123)) {
//if('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.indexOf(S[pos])!==-1 ){
var name = parseName();
// search beginning of the string
var code = S.charCodeAt(pos);
while (code && code !== singleQuoteCC && code !== doubleQuoteCC && !((code > 64 && code < 91) || (code > 96 && code < 123)) && code !== closeBracketCC) {
pos++;
code = S.charCodeAt(pos);
}
if (code === singleQuoteCC || code === doubleQuoteCC) {
var value = parseString();
if (pos === -1) {
return {
tagName,
attributes,
children,
};
}
} else {
value = null;
pos--;
}
attributes[name] = value;
}
pos++;
}
// optional parsing of children
if (S.charCodeAt(pos - 1) !== slashCC) {
if (tagName == "script") {
var start = pos + 1;
pos = S.indexOf('</script>', pos);
children = [S.slice(start, pos - 1)];
pos += 9;
} else if (tagName == "style") {
var start = pos + 1;
pos = S.indexOf('</style>', pos);
children = [S.slice(start, pos - 1)];
pos += 8;
} else if (NoChildNodes.indexOf(tagName) == -1) {
pos++;
children = parseChildren(name);
}
} else {
pos++;
}
return {
tagName,
attributes,
children,
};
}
/**
* is parsing a string, that starts with a char and with the same usually ' or "
*/
function parseString() {
var startChar = S[pos];
var startpos = ++pos;
pos = S.indexOf(startChar, startpos)
return S.slice(startpos, pos);
}
/**
*
*/
function findElements() {
var r = new RegExp('\\s' + options.attrName + '\\s*=[\'"]' + options.attrValue + '[\'"]').exec(S)
if (r) {
return r.index;
} else {
return -1;
}
}
var out = null;
if (options.attrValue !== undefined) {
options.attrName = options.attrName || 'id';
var out = [];
while ((pos = findElements()) !== -1) {
pos = S.lastIndexOf('<', pos);
if (pos !== -1) {
out.push(parseNode());
}
S = S.substr(pos);
pos = 0;
}
} else if (options.parseNode) {
out = parseNode()
} else {
out = parseChildren();
}
if (options.filter) {
out = tXml.filter(out, options.filter);
}
if (options.setPos) {
out.pos = pos;
}
return out;
}
/**
* transform the DomObject to an object that is like the object of PHPs simplexmp_load_*() methods.
* this format helps you to write that is more likely to keep your programm working, even if there a small changes in the XML schema.
* be aware, that it is not possible to reproduce the original xml from a simplified version, because the order of elements is not saved.
* therefore your programm will be more flexible and easyer to read.
*
* @param {tNode[]} children the childrenList
*/
tXml.simplify = function simplify(children) {
var out = {};
if (!children.length) {
return '';
}
if (children.length === 1 && typeof children[0] == 'string') {
return children[0];
}
// map each object
children.forEach(function(child) {
if (typeof child !== 'object') {
return;
}
if (!out[child.tagName])
out[child.tagName] = [];
var kids = tXml.simplify(child.children||[]);
out[child.tagName].push(kids);
if (child.attributes) {
kids._attributes = child.attributes;
}
});
for (var i in out) {
if (out[i].length == 1) {
out[i] = out[i][0];
}
}
return out;
};
/**
* behaves the same way as Array.filter, if the filter method return true, the element is in the resultList
* @params children{Array} the children of a node
* @param f{function} the filter method
*/
tXml.filter = function(children, f) {
var out = [];
children.forEach(function(child) {
if (typeof(child) === 'object' && f(child)) out.push(child);
if (child.children) {
var kids = tXml.filter(child.children, f);
out = out.concat(kids);
}
});
return out;
};
/**
* stringify a previously parsed string object.
* this is useful,
* 1. to remove whitespaces
* 2. to recreate xml data, with some changed data.
* @param {tNode} O the object to Stringify
*/
tXml.stringify = function TOMObjToXML(O) {
var out = '';
function writeChildren(O) {
if (O)
for (var i = 0; i < O.length; i++) {
if (typeof O[i] == 'string') {
out += O[i].trim();
} else {
writeNode(O[i]);
}
}
}
function writeNode(N) {
out += "<" + N.tagName;
for (var i in N.attributes) {
if (N.attributes[i] === null) {
out += ' ' + i;
} else if (N.attributes[i].indexOf('"') === -1) {
out += ' ' + i + '="' + N.attributes[i].trim() + '"';
} else {
out += ' ' + i + "='" + N.attributes[i].trim() + "'";
}
}
out += '>';
writeChildren(N.children);
out += '</' + N.tagName + '>';
}
writeChildren(O);
return out;
};
/**
* use this method to read the textcontent, of some node.
* It is great if you have mixed content like:
* this text has some <b>big</b> text and a <a href=''>link</a>
* @return {string}
*/
tXml.toContentString = function(tDom) {
if (Array.isArray(tDom)) {
var out = '';
tDom.forEach(function(e) {
out += ' ' + tXml.toContentString(e);
out = out.trim();
});
return out;
} else if (typeof tDom === 'object') {
return tXml.toContentString(tDom.children)
} else {
return ' ' + tDom;
}
};
tXml.getElementById = function(S, id, simplified) {
var out = tXml(S, {
attrValue: id
});
return simplified ? tXml.simplify(out) : out[0];
};
/**
* A fast parsing method, that not realy finds by classname,
* more: the class attribute contains XXX
* @param
*/
tXml.getElementsByClassName = function(S, classname, simplified) {
const out = tXml(S, {
attrName: 'class',
attrValue: '[a-zA-Z0-9\-\s ]*' + classname + '[a-zA-Z0-9\-\s ]*'
});
return simplified ? tXml.simplify(out) : out;
};
tXml.parseStream = function(stream, offset) {
if (typeof offset === 'string') {
offset = offset.length + 2;
}
if (typeof stream === 'string') {
var fs = require('fs');
stream = fs.createReadStream(stream, { start: offset });
offset = 0;
}
var position = offset;
var data = '';
stream.on('data', function(chunk) {
data += chunk;
var lastPos = 0;
do {
position = data.indexOf('<', position) + 1;
if(!position) {
position = lastPos;
return;
}
if (data[position + 1] === '/') {
position = position + 1;
lastPos = pos;
continue;
}
var res = tXml(data, { pos: position-1, parseNode: true, setPos: true });
position = res.pos;
if (position > (data.length - 1) || position < lastPos) {
data = data.slice(lastPos);
position = 0;
lastPos = 0;
return;
} else {
stream.emit('xml', res);
lastPos = position;
}
} while (1);
});
stream.on('end', function() {
console.log('end')
});
return stream;
}
tXml.transformStream = function (offset) {
// require through here, so it will not get added to webpack/browserify
const through2 = require('through2');
if (typeof offset === 'string') {
offset = offset.length + 2;
}
var position = offset || 0;
var data = '';
const stream = through2({ readableObjectMode: true }, function (chunk, enc, callback) {
data += chunk;
var lastPos = 0;
do {
position = data.indexOf('<', position) + 1;
if (!position) {
position = lastPos;
return callback();;
}
if (data[position + 1] === '/') {
position = position + 1;
lastPos = pos;
continue;
}
var res = tXml(data, { pos: position - 1, parseNode: true, setPos: true });
position = res.pos;
if (position > (data.length - 1) || position < lastPos) {
data = data.slice(lastPos);
position = 0;
lastPos = 0;
return callback();;
} else {
this.push(res);
lastPos = position;
}
} while (1);
callback();
});
return stream;
}
if ('object' === typeof module) {
module.exports = tXml;
tXml.xml = tXml;
}
// ==UserScript==
// @name bilibili ASS Danmaku Downloader
// @namespace https://github.com/tiansh
// @description 以 ASS 格式下载 bilibili 的弹幕
// @include http://www.bilibili.com/video/av*
// @include http://bangumi.bilibili.com/movie/*
// @updateURL https://tiansh.github.io/us-danmaku/bilibili/bilibili_ASS_Danmaku_Downloader.meta.js
// @downloadURL https://tiansh.github.io/us-danmaku/bilibili/bilibili_ASS_Danmaku_Downloader.user.js
// @version 1.11
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @run-at document-start
// @author 田生
// @copyright 2014+, 田生
// @license Mozilla Public License 2.0; http://www.mozilla.org/MPL/2.0/
// @license CC Attribution-ShareAlike 4.0 International; http://creativecommons.org/licenses/by-sa/4.0/
// @connect-src comment.bilibili.com
// @connect-src interface.bilibili.com
// ==/UserScript==
/*
* Common
*/
// 设置项
var config = {
'playResX': 1280, // 屏幕分辨率宽(像素)
'playResY': 720, // 屏幕分辨率高(像素)
'fontlist': [ // 字形(会自动选择最前面一个可用的)
'Microsoft YaHei UI',
'Microsoft YaHei',
'文泉驿正黑',
'STHeitiSC',
'黑体',
],
'font_size': 1.0, // 字号(比例)
'r2ltime': 8, // 右到左弹幕持续时间(秒)
'fixtime': 4, // 固定弹幕持续时间(秒)
'opacity': 0.6, // 不透明度(比例)
'space': 0, // 弹幕间隔的最小水平距离(像素)
'max_delay': 6, // 最多允许延迟几秒出现弹幕
'bottom': 50, // 底端给字幕保留的空间(像素)
'use_canvas': null, // 是否使用canvas计算文本宽度(布尔值,Linux下的火狐默认否,其他默认是,Firefox bug #561361)
'debug': false, // 打印调试信息
};
var debug = config.debug ? console.log.bind(console) : function () { };
// 将字典中的值填入字符串
var fillStr = function (str) {
var dict = Array.apply(Array, arguments);
return str.replace(/{{([^}]+)}}/g, function (r, o) {
var ret;
dict.some(function (i) { return ret = i[o]; });
return ret || '';
});
};
// 将颜色的数值化为十六进制字符串表示
var RRGGBB = function (color) {
var t = Number(color).toString(16).toUpperCase();
return (Array(7).join('0') + t).slice(-6);
};
// 将可见度转换为透明度
var hexAlpha = function (opacity) {
var alpha = Math.round(0xFF * (1 - opacity)).toString(16).toUpperCase();
return Array(3 - alpha.length).join('0') + alpha;
};
// 字符串
var funStr = function (fun) {
return fun.toString().split(/\r\n|\n|\r/).slice(1, -1).join('\n');
};
// 平方和开根
var hypot = Math.hypot ? Math.hypot.bind(Math) : function () {
return Math.sqrt([0].concat(Array.apply(Array, arguments))
.reduce(function (x, y) { return x + y * y; }));
};
// 计算文字宽度
var calcWidth = (function () {
// 使用Canvas计算
var calcWidth = function () {
let isSlim = new RegExp("[A-Za-z0-9]+");
return function (fontname, text, fontsize) {
let width = 0;
for (let c of text) {
if (isSlim.test(c)) {
width += fontsize / 2;
} else {
width += fontsize;
}
}
return Math.ceil(width + config.space);
};
}
return calcWidth();
}());
// 选择合适的字体
var choseFont = function (fontlist) {
// 检查这个字串的宽度来检查字体是否存在
var sampleText =
'The quick brown fox jumps over the lazy dog' +
'7531902468' + ',.!-' + ',。:!' +
'天地玄黄' + '則近道矣';
// 和这些字体进行比较
var sampleFont = [
'monospace', 'sans-serif', 'sans',
'Symbol', 'Arial', 'Comic Sans MS', 'Fixed', 'Terminal',
'Times', 'Times New Roman',
'宋体', '黑体', '文泉驿正黑', 'Microsoft YaHei'
];
// 如果被检查的字体和基准字体可以渲染出不同的宽度
// 那么说明被检查的字体总是存在的
var diffFont = function (base, test) {
var baseSize = calcWidth(base, sampleText, 72);
var testSize = calcWidth(test + ',' + base, sampleText, 72);
return baseSize !== testSize;
};
var validFont = function (test) {
var valid = sampleFont.some(function (base) {
return diffFont(base, test);
});
debug('font %s: %o', test, valid);
return valid;
};
// 找一个能用的字体
var f = fontlist[fontlist.length - 1];
fontlist = fontlist.filter(validFont);
debug('fontlist: %o', fontlist);
return fontlist[0] || f;
};
// 从备选的字体中选择一个机器上提供了的字体
var initFont = (function () {
var done = false;
return function () {
if (done) return; done = true;
calcWidth = calcWidth.bind(null,
config.font = choseFont(config.fontlist)
);
};
}());
var generateASS = function (danmaku, info) {
var assHeader = fillStr(`[Script Info]
Title: {{title}}
Original Script: 根据 {{ori}} 的弹幕信息,由 https://github.com/tiansh/us-danmaku 生成
ScriptType: v4.00+
Collisions: Normal
PlayResX: {{playResX}}
PlayResY: {{playResY}}
Timer: 10.0000
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Fix,{{font}},25,&H{{alpha}}FFFFFF,&H{{alpha}}FFFFFF,&H{{alpha}}000000,&H{{alpha}}000000,1,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0
Style: R2L,{{font}},25,&H{{alpha}}FFFFFF,&H{{alpha}}FFFFFF,&H{{alpha}}000000,&H{{alpha}}000000,1,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
`, config, info, {'alpha': hexAlpha(config.opacity) });
// 补齐数字开头的0
var paddingNum = function (num, len) {
num = '' + num;
while (num.length < len) num = '0' + num;
return num;
};
// 格式化时间
var formatTime = function (time) {
time = 100 * time ^ 0;
var l = [[100, 2], [60, 2], [60, 2], [Infinity, 0]].map(function (c) {
var r = time % c[0];
time = (time - r) / c[0];
return paddingNum(r, c[1]);
}).reverse();
return l.slice(0, -1).join(':') + '.' + l[3];
};
// 格式化特效
var format = (function () {
// 适用于所有弹幕
var common = function (line) {
var s = '';
var rgb = line.color.split(/(..)/).filter(function (x) { return x; })
.map(function (x) { return parseInt(x, 16); });
// 如果不是白色,要指定弹幕特殊的颜色
if (line.color !== 'FFFFFF') // line.color 是 RRGGBB 格式
s += '\\c&H' + line.color.split(/(..)/).reverse().join('');
// 如果弹幕颜色比较深,用白色的外边框
var dark = rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114 < 0x30;
if (dark) s += '\\3c&HFFFFFF';
if (line.size !== 25) s += '\\fs' + line.size;
return s;
};
// 适用于从右到左弹幕
var r2l = function (line) {
return '\\move(' + [
line.poss.x, line.poss.y, line.posd.x, line.posd.y
].join(',') + ')';
};
// 适用于固定位置弹幕
var fix = function (line) {
return '\\pos(' + [
line.poss.x, line.poss.y
].join(',') + ')';
};
var withCommon = function (f) {
return function (line) { return f(line) + common(line); };
};
return {
'R2L': withCommon(r2l),
'Fix': withCommon(fix),
};
}());
// 转义一些字符
var escapeAssText = function (s) {
// "{"、"}"字符libass可以转义,但是VSFilter不可以,所以直接用全角补上
return s.replace(/{/g, '{').replace(/}/g, '}').replace(/\r|\n/g, '');
};
// 将一行转换为ASS的事件
var convert2Ass = function (line) {
return 'Dialogue: ' + [
0,
formatTime(line.stime),
formatTime(line.dtime),
line.type,
',20,20,2,,',
].join(',')
+ '{' + format[line.type](line) + '}'
+ escapeAssText(line.text);
};
return assHeader +
danmaku.map(convert2Ass)
.filter(function (x) { return x; })
.join('\n');
};
/*
下文字母含义:
0 ||----------------------x---------------------->
_____________________c_____________________
= / wc \ 0
| | |--v--| wv | |--v--|
| d |--v--| d f |--v--|
y |--v--| l f | s _ p
| | VIDEO |--v--| |--v--| _ m
v | AREA (x ^ y) |
v: 弹幕
c: 屏幕
0: 弹幕发送
a: 可行方案
s: 开始出现
f: 出现完全
l: 开始消失
d: 消失完全
p: 上边缘(含)
m: 下边缘(不含)
w: 宽度
h: 高度
b: 底端保留
t: 时间点
u: 时间段
r: 延迟
并规定
ts := t0s + r
tf := wv / (wc + ws) * p + ts
tl := ws / (wc + ws) * p + ts
td := p + ts
*/
// 滚动弹幕
var normalDanmaku = (function (wc, hc, b, u, maxr) {
return function () {
// 初始化屏幕外面是不可用的
var used = [
{ 'p': -Infinity, 'm': 0, 'tf': Infinity, 'td': Infinity, 'b': false },
{ 'p': hc, 'm': Infinity, 'tf': Infinity, 'td': Infinity, 'b': false },
{ 'p': hc - b, 'm': hc, 'tf': Infinity, 'td': Infinity, 'b': true },
];
// 检查一些可用的位置
var available = function (hv, t0s, t0l, b) {
var suggestion = [];
// 这些上边缘总之别的块的下边缘
used.forEach(function (i) {
if (i.m > hc) return;
var p = i.m;
var m = p + hv;
var tas = t0s;
var tal = t0l;
// 这些块的左边缘总是这个区域里面最大的边缘
used.forEach(function (j) {
if (j.p >= m) return;
if (j.m <= p) return;
if (j.b && b) return;
tas = Math.max(tas, j.tf);
tal = Math.max(tal, j.td);
});
// 最后作为一种备选留下来
suggestion.push({
'p': p,
'r': Math.max(tas - t0s, tal - t0l),
});
});
// 根据高度排序
suggestion.sort(function (x, y) { return x.p - y.p; });
var mr = maxr;
// 又靠右又靠下的选择可以忽略,剩下的返回
suggestion = suggestion.filter(function (i) {
if (i.r >= mr) return false;
mr = i.r;
return true;
});
return suggestion;
};
// 添加一个被使用的
var use = function (p, m, tf, td) {
used.push({ 'p': p, 'm': m, 'tf': tf, 'td': td, 'b': false });
};
// 根据时间同步掉无用的
var syn = function (t0s, t0l) {
used = used.filter(function (i) { return i.tf > t0s || i.td > t0l; });
};
// 给所有可能的位置打分,分数是[0, 1)的
var score = function (i) {
if (i.r > maxr) return -Infinity;
return 1 - hypot(i.r / maxr, i.p / hc) * Math.SQRT1_2;
};
// 添加一条
return function (t0s, wv, hv, b) {
var t0l = wc / (wv + wc) * u + t0s;
syn(t0s, t0l);
var al = available(hv, t0s, t0l, b);
if (!al.length) return null;
var scored = al.map(function (i) { return [score(i), i]; });
var best = scored.reduce(function (x, y) {
return x[0] > y[0] ? x : y;
})[1];
var ts = t0s + best.r;
var tf = wv / (wv + wc) * u + ts;
var td = u + ts;
use(best.p, best.p + hv, tf, td);
return {
'top': best.p,
'time': ts,
};
};
};
}(config.playResX, config.playResY, config.bottom, config.r2ltime, config.max_delay));
// 顶部、底部弹幕
var sideDanmaku = (function (hc, b, u, maxr) {
return function () {
var used = [
{ 'p': -Infinity, 'm': 0, 'td': Infinity, 'b': false },
{ 'p': hc, 'm': Infinity, 'td': Infinity, 'b': false },
{ 'p': hc - b, 'm': hc, 'td': Infinity, 'b': true },
];
// 查找可用的位置
var fr = function (p, m, t0s, b) {
var tas = t0s;
used.forEach(function (j) {
if (j.p >= m) return;
if (j.m <= p) return;
if (j.b && b) return;
tas = Math.max(tas, j.td);
});
return { 'r': tas - t0s, 'p': p, 'm': m };
};
// 顶部
var top = function (hv, t0s, b) {
var suggestion = [];
used.forEach(function (i) {
if (i.m > hc) return;
suggestion.push(fr(i.m, i.m + hv, t0s, b));
});
return suggestion;
};
// 底部
var bottom = function (hv, t0s, b) {
var suggestion = [];
used.forEach(function (i) {
if (i.p < 0) return;
suggestion.push(fr(i.p - hv, i.p, t0s, b));
});
return suggestion;
};
var use = function (p, m, td) {
used.push({ 'p': p, 'm': m, 'td': td, 'b': false });
};
var syn = function (t0s) {
used = used.filter(function (i) { return i.td > t0s; });
};
// 挑选最好的方案:延迟小的优先,位置不重要
var score = function (i, is_top) {
if (i.r > maxr) return -Infinity;
var f = function (p) { return is_top ? p : (hc - p); };
return 1 - (i.r / maxr * (31/32) + f(i.p) / hc * (1/32));
};
return function (t0s, hv, is_top, b) {
syn(t0s);
var al = (is_top ? top : bottom)(hv, t0s, b);
if (!al.length) return null;
var scored = al.map(function (i) { return [score(i, is_top), i]; });
var best = scored.reduce(function (x, y) {
return x[0] > y[0] ? x : y;
})[1];
use(best.p, best.m, best.r + t0s + u)
return { 'top': best.p, 'time': best.r + t0s };
};
};
}(config.playResY, config.bottom, config.fixtime, config.max_delay));
// 为每条弹幕安置位置
var setPosition = function (danmaku) {
var normal = normalDanmaku(), side = sideDanmaku();
return danmaku
.sort(function (x, y) { return x.time - y.time; })
.map(function (line) {
var font_size = Math.round(line.size * config.font_size);
var width = calcWidth(line.text, font_size);
switch (line.mode) {
case 'R2L': return (function () {
var pos = normal(line.time, width, font_size, line.bottom);
if (!pos) return null;
line.type = 'R2L';
line.stime = pos.time;
line.poss = {
'x': config.playResX + width / 2,
'y': pos.top + font_size,
};
line.posd = {
'x': -width / 2,
'y': pos.top + font_size,
};
line.dtime = config.r2ltime + line.stime;
return line;
}());
case 'TOP': case 'BOTTOM': return (function (isTop) {
var pos = side(line.time, font_size, isTop, line.bottom);
if (!pos) return null;
line.type = 'Fix';
line.stime = pos.time;
line.posd = line.poss = {
'x': Math.round(config.playResX / 2),
'y': pos.top + font_size,
};
line.dtime = config.fixtime + line.stime;
return line;
}(line.mode === 'TOP'));
default: return null;
};
})
.filter(function (l) { return l; })
.sort(function (x, y) { return x.stime - y.stime; });
};
/*
* bilibili
*/
// 获取xml
var fetchXML = function (filename, callback) {
let f = std.open(filename, 'r');
let data = f.readAsString();
var content = new String(data).replace(/(?:[\0-\x08\x0B\f\x0E-\x1F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF])/g, "");
callback(content);
};
var fetchDanmaku = function (filename, callback) {
fetchXML(filename, function (content) {
parseXML(content, function(data) {
callback(data);
})
});
};
var parseXML = function (content, cb) {
let result = tXml(content, {simplify: 1});
cb(result[0].children[0].children.map(function(d) {
if (d.tagName == 'd') {
var info = d.attributes.p.split(','), text = d.children[0];
return {
'text': text,
'time': Number(info[0]),
'mode': [undefined, 'R2L', 'R2L', 'R2L', 'BOTTOM', 'TOP'][Number(info[1])],
'size': Number(info[2]),
'color': RRGGBB(parseInt(info[3], 10) & 0xffffff),
'bottom': Number(info[5]) > 0,
// 'create': new Date(Number(info[4])),
// 'pool': Number(info[5]),
// 'sender': String(info[6]),
// 'dmid': Number(info[7]),
};
} else {
return null;
}
}));
};
// 初始化
var init = function () {
initFont();
};
function main() {
init();
let index = 1;
let totalDanmaku = [];
let maxTime = 0;
function doWork() {
if (index >= scriptArgs.length) {
var ass = generateASS(setPosition(totalDanmaku), {
'title': "live.list",
'ori': "live.list",
});
console.log(ass);
} else {
let filename = scriptArgs[index];
fetchDanmaku(filename, function (danmaku) {
debug('got xml with %d danmaku', danmaku.length);
let thisMaxTime = 0;
for (let d of danmaku) {
if (!d) {
continue
}
let time = d.time + maxTime;
d.time = time;
if (time > thisMaxTime) {
thisMaxTime = time;
}
totalDanmaku.push(d);
}
if (thisMaxTime > maxTime) {
maxTime = thisMaxTime;
}
index++;
os.setTimeout(doWork, 0);
});
}
}
doWork();
}
main();
var parseString = require('xml2js').parseString;
var fs = require('fs');
// ==UserScript==
// @name bilibili ASS Danmaku Downloader
// @namespace https://github.com/tiansh
// @description 以 ASS 格式下载 bilibili 的弹幕
// @include http://www.bilibili.com/video/av*
// @include http://bangumi.bilibili.com/movie/*
// @updateURL https://tiansh.github.io/us-danmaku/bilibili/bilibili_ASS_Danmaku_Downloader.meta.js
// @downloadURL https://tiansh.github.io/us-danmaku/bilibili/bilibili_ASS_Danmaku_Downloader.user.js
// @version 1.11
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @run-at document-start
// @author 田生
// @copyright 2014+, 田生
// @license Mozilla Public License 2.0; http://www.mozilla.org/MPL/2.0/
// @license CC Attribution-ShareAlike 4.0 International; http://creativecommons.org/licenses/by-sa/4.0/
// @connect-src comment.bilibili.com
// @connect-src interface.bilibili.com
// ==/UserScript==
/*
* Common
*/
// 设置项
var config = {
'playResX': 1280, // 屏幕分辨率宽(像素)
'playResY': 720, // 屏幕分辨率高(像素)
'fontlist': [ // 字形(会自动选择最前面一个可用的)
'Microsoft YaHei UI',
'Microsoft YaHei',
'文泉驿正黑',
'STHeitiSC',
'黑体',
],
'font_size': 1.0, // 字号(比例)
'r2ltime': 8, // 右到左弹幕持续时间(秒)
'fixtime': 4, // 固定弹幕持续时间(秒)
'opacity': 0.6, // 不透明度(比例)
'space': 0, // 弹幕间隔的最小水平距离(像素)
'max_delay': 6, // 最多允许延迟几秒出现弹幕
'bottom': 50, // 底端给字幕保留的空间(像素)
'use_canvas': null, // 是否使用canvas计算文本宽度(布尔值,Linux下的火狐默认否,其他默认是,Firefox bug #561361)
'debug': false, // 打印调试信息
};
var debug = config.debug ? console.log.bind(console) : function () { };
// 将字典中的值填入字符串
var fillStr = function (str) {
var dict = Array.apply(Array, arguments);
return str.replace(/{{([^}]+)}}/g, function (r, o) {
var ret;
dict.some(function (i) { return ret = i[o]; });
return ret || '';
});
};
// 将颜色的数值化为十六进制字符串表示
var RRGGBB = function (color) {
var t = Number(color).toString(16).toUpperCase();
return (Array(7).join('0') + t).slice(-6);
};
// 将可见度转换为透明度
var hexAlpha = function (opacity) {
var alpha = Math.round(0xFF * (1 - opacity)).toString(16).toUpperCase();
return Array(3 - alpha.length).join('0') + alpha;
};
// 字符串
var funStr = function (fun) {
return fun.toString().split(/\r\n|\n|\r/).slice(1, -1).join('\n');
};
// 平方和开根
var hypot = Math.hypot ? Math.hypot.bind(Math) : function () {
return Math.sqrt([0].concat(Array.apply(Array, arguments))
.reduce(function (x, y) { return x + y * y; }));
};
// 计算文字宽度
var calcWidth = (function () {
// 使用Canvas计算
var calcWidth = function () {
let isSlim = new RegExp("[A-Za-z0-9]+");
return function (fontname, text, fontsize) {
let width = 0;
for (let c of text) {
if (isSlim.test(c)) {
width += fontsize / 2;
} else {
width += fontsize;
}
}
return Math.ceil(width + config.space);
};
}
return calcWidth();
}());
// 选择合适的字体
var choseFont = function (fontlist) {
// 检查这个字串的宽度来检查字体是否存在
var sampleText =
'The quick brown fox jumps over the lazy dog' +
'7531902468' + ',.!-' + ',。:!' +
'天地玄黄' + '則近道矣';
// 和这些字体进行比较
var sampleFont = [
'monospace', 'sans-serif', 'sans',
'Symbol', 'Arial', 'Comic Sans MS', 'Fixed', 'Terminal',
'Times', 'Times New Roman',
'宋体', '黑体', '文泉驿正黑', 'Microsoft YaHei'
];
// 如果被检查的字体和基准字体可以渲染出不同的宽度
// 那么说明被检查的字体总是存在的
var diffFont = function (base, test) {
var baseSize = calcWidth(base, sampleText, 72);
var testSize = calcWidth(test + ',' + base, sampleText, 72);
return baseSize !== testSize;
};
var validFont = function (test) {
var valid = sampleFont.some(function (base) {
return diffFont(base, test);
});
debug('font %s: %o', test, valid);
return valid;
};
// 找一个能用的字体
var f = fontlist[fontlist.length - 1];
fontlist = fontlist.filter(validFont);
debug('fontlist: %o', fontlist);
return fontlist[0] || f;
};
// 从备选的字体中选择一个机器上提供了的字体
var initFont = (function () {
var done = false;
return function () {
if (done) return; done = true;
calcWidth = calcWidth.bind(null,
config.font = choseFont(config.fontlist)
);
};
}());
var generateASS = function (danmaku, info) {
var assHeader = fillStr(`[Script Info]
Title: {{title}}
Original Script: 根据 {{ori}} 的弹幕信息,由 https://github.com/tiansh/us-danmaku 生成
ScriptType: v4.00+
Collisions: Normal
PlayResX: {{playResX}}
PlayResY: {{playResY}}
Timer: 10.0000
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Fix,{{font}},25,&H{{alpha}}FFFFFF,&H{{alpha}}FFFFFF,&H{{alpha}}000000,&H{{alpha}}000000,1,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0
Style: R2L,{{font}},25,&H{{alpha}}FFFFFF,&H{{alpha}}FFFFFF,&H{{alpha}}000000,&H{{alpha}}000000,1,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
`, config, info, {'alpha': hexAlpha(config.opacity) });
// 补齐数字开头的0
var paddingNum = function (num, len) {
num = '' + num;
while (num.length < len) num = '0' + num;
return num;
};
// 格式化时间
var formatTime = function (time) {
time = 100 * time ^ 0;
var l = [[100, 2], [60, 2], [60, 2], [Infinity, 0]].map(function (c) {
var r = time % c[0];
time = (time - r) / c[0];
return paddingNum(r, c[1]);
}).reverse();
return l.slice(0, -1).join(':') + '.' + l[3];
};
// 格式化特效
var format = (function () {
// 适用于所有弹幕
var common = function (line) {
var s = '';
var rgb = line.color.split(/(..)/).filter(function (x) { return x; })
.map(function (x) { return parseInt(x, 16); });
// 如果不是白色,要指定弹幕特殊的颜色
if (line.color !== 'FFFFFF') // line.color 是 RRGGBB 格式
s += '\\c&H' + line.color.split(/(..)/).reverse().join('');
// 如果弹幕颜色比较深,用白色的外边框
var dark = rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114 < 0x30;
if (dark) s += '\\3c&HFFFFFF';
if (line.size !== 25) s += '\\fs' + line.size;
return s;
};
// 适用于从右到左弹幕
var r2l = function (line) {
return '\\move(' + [
line.poss.x, line.poss.y, line.posd.x, line.posd.y
].join(',') + ')';
};
// 适用于固定位置弹幕
var fix = function (line) {
return '\\pos(' + [
line.poss.x, line.poss.y
].join(',') + ')';
};
var withCommon = function (f) {
return function (line) { return f(line) + common(line); };
};
return {
'R2L': withCommon(r2l),
'Fix': withCommon(fix),
};
}());
// 转义一些字符
var escapeAssText = function (s) {
// "{"、"}"字符libass可以转义,但是VSFilter不可以,所以直接用全角补上
return s.replace(/{/g, '{').replace(/}/g, '}').replace(/\r|\n/g, '');
};
// 将一行转换为ASS的事件
var convert2Ass = function (line) {
return 'Dialogue: ' + [
0,
formatTime(line.stime),
formatTime(line.dtime),
line.type,
',20,20,2,,',
].join(',')
+ '{' + format[line.type](line) + '}'
+ escapeAssText(line.text);
};
return assHeader +
danmaku.map(convert2Ass)
.filter(function (x) { return x; })
.join('\n');
};
/*
下文字母含义:
0 ||----------------------x---------------------->
_____________________c_____________________
= / wc \ 0
| | |--v--| wv | |--v--|
| d |--v--| d f |--v--|
y |--v--| l f | s _ p
| | VIDEO |--v--| |--v--| _ m
v | AREA (x ^ y) |
v: 弹幕
c: 屏幕
0: 弹幕发送
a: 可行方案
s: 开始出现
f: 出现完全
l: 开始消失
d: 消失完全
p: 上边缘(含)
m: 下边缘(不含)
w: 宽度
h: 高度
b: 底端保留
t: 时间点
u: 时间段
r: 延迟
并规定
ts := t0s + r
tf := wv / (wc + ws) * p + ts
tl := ws / (wc + ws) * p + ts
td := p + ts
*/
// 滚动弹幕
var normalDanmaku = (function (wc, hc, b, u, maxr) {
return function () {
// 初始化屏幕外面是不可用的
var used = [
{ 'p': -Infinity, 'm': 0, 'tf': Infinity, 'td': Infinity, 'b': false },
{ 'p': hc, 'm': Infinity, 'tf': Infinity, 'td': Infinity, 'b': false },
{ 'p': hc - b, 'm': hc, 'tf': Infinity, 'td': Infinity, 'b': true },
];
// 检查一些可用的位置
var available = function (hv, t0s, t0l, b) {
var suggestion = [];
// 这些上边缘总之别的块的下边缘
used.forEach(function (i) {
if (i.m > hc) return;
var p = i.m;
var m = p + hv;
var tas = t0s;
var tal = t0l;
// 这些块的左边缘总是这个区域里面最大的边缘
used.forEach(function (j) {
if (j.p >= m) return;
if (j.m <= p) return;
if (j.b && b) return;
tas = Math.max(tas, j.tf);
tal = Math.max(tal, j.td);
});
// 最后作为一种备选留下来
suggestion.push({
'p': p,
'r': Math.max(tas - t0s, tal - t0l),
});
});
// 根据高度排序
suggestion.sort(function (x, y) { return x.p - y.p; });
var mr = maxr;
// 又靠右又靠下的选择可以忽略,剩下的返回
suggestion = suggestion.filter(function (i) {
if (i.r >= mr) return false;
mr = i.r;
return true;
});
return suggestion;
};
// 添加一个被使用的
var use = function (p, m, tf, td) {
used.push({ 'p': p, 'm': m, 'tf': tf, 'td': td, 'b': false });
};
// 根据时间同步掉无用的
var syn = function (t0s, t0l) {
used = used.filter(function (i) { return i.tf > t0s || i.td > t0l; });
};
// 给所有可能的位置打分,分数是[0, 1)的
var score = function (i) {
if (i.r > maxr) return -Infinity;
return 1 - hypot(i.r / maxr, i.p / hc) * Math.SQRT1_2;
};
// 添加一条
return function (t0s, wv, hv, b) {
var t0l = wc / (wv + wc) * u + t0s;
syn(t0s, t0l);
var al = available(hv, t0s, t0l, b);
if (!al.length) return null;
var scored = al.map(function (i) { return [score(i), i]; });
var best = scored.reduce(function (x, y) {
return x[0] > y[0] ? x : y;
})[1];
var ts = t0s + best.r;
var tf = wv / (wv + wc) * u + ts;
var td = u + ts;
use(best.p, best.p + hv, tf, td);
return {
'top': best.p,
'time': ts,
};
};
};
}(config.playResX, config.playResY, config.bottom, config.r2ltime, config.max_delay));
// 顶部、底部弹幕
var sideDanmaku = (function (hc, b, u, maxr) {
return function () {
var used = [
{ 'p': -Infinity, 'm': 0, 'td': Infinity, 'b': false },
{ 'p': hc, 'm': Infinity, 'td': Infinity, 'b': false },
{ 'p': hc - b, 'm': hc, 'td': Infinity, 'b': true },
];
// 查找可用的位置
var fr = function (p, m, t0s, b) {
var tas = t0s;
used.forEach(function (j) {
if (j.p >= m) return;
if (j.m <= p) return;
if (j.b && b) return;
tas = Math.max(tas, j.td);
});
return { 'r': tas - t0s, 'p': p, 'm': m };
};
// 顶部
var top = function (hv, t0s, b) {
var suggestion = [];
used.forEach(function (i) {
if (i.m > hc) return;
suggestion.push(fr(i.m, i.m + hv, t0s, b));
});
return suggestion;
};
// 底部
var bottom = function (hv, t0s, b) {
var suggestion = [];
used.forEach(function (i) {
if (i.p < 0) return;
suggestion.push(fr(i.p - hv, i.p, t0s, b));
});
return suggestion;
};
var use = function (p, m, td) {
used.push({ 'p': p, 'm': m, 'td': td, 'b': false });
};
var syn = function (t0s) {
used = used.filter(function (i) { return i.td > t0s; });
};
// 挑选最好的方案:延迟小的优先,位置不重要
var score = function (i, is_top) {
if (i.r > maxr) return -Infinity;
var f = function (p) { return is_top ? p : (hc - p); };
return 1 - (i.r / maxr * (31/32) + f(i.p) / hc * (1/32));
};
return function (t0s, hv, is_top, b) {
syn(t0s);
var al = (is_top ? top : bottom)(hv, t0s, b);
if (!al.length) return null;
var scored = al.map(function (i) { return [score(i, is_top), i]; });
var best = scored.reduce(function (x, y) {
return x[0] > y[0] ? x : y;
})[1];
use(best.p, best.m, best.r + t0s + u)
return { 'top': best.p, 'time': best.r + t0s };
};
};
}(config.playResY, config.bottom, config.fixtime, config.max_delay));
// 为每条弹幕安置位置
var setPosition = function (danmaku) {
var normal = normalDanmaku(), side = sideDanmaku();
return danmaku
.sort(function (x, y) { return x.time - y.time; })
.map(function (line) {
var font_size = Math.round(line.size * config.font_size);
var width = calcWidth(line.text, font_size);
switch (line.mode) {
case 'R2L': return (function () {
var pos = normal(line.time, width, font_size, line.bottom);
if (!pos) return null;
line.type = 'R2L';
line.stime = pos.time;
line.poss = {
'x': config.playResX + width / 2,
'y': pos.top + font_size,
};
line.posd = {
'x': -width / 2,
'y': pos.top + font_size,
};
line.dtime = config.r2ltime + line.stime;
return line;
}());
case 'TOP': case 'BOTTOM': return (function (isTop) {
var pos = side(line.time, font_size, isTop, line.bottom);
if (!pos) return null;
line.type = 'Fix';
line.stime = pos.time;
line.posd = line.poss = {
'x': Math.round(config.playResX / 2),
'y': pos.top + font_size,
};
line.dtime = config.fixtime + line.stime;
return line;
}(line.mode === 'TOP'));
default: return null;
};
})
.filter(function (l) { return l; })
.sort(function (x, y) { return x.stime - y.stime; });
};
/*
* bilibili
*/
// 获取xml
var fetchXML = function (filename, callback) {
fs.readFile(filename, (err, data) => {
if (err) throw err;
var content = new String(data).replace(/(?:[\0-\x08\x0B\f\x0E-\x1F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF])/g, "");
callback(content);
})
};
var fetchDanmaku = function (filename, callback) {
fetchXML(filename, function (content) {
parseXML(content, function(data) {
callback(data);
})
});
};
var parseXML = function (content, cb) {
parseString(content, function (err, result) {
let dList = result.i.d;
cb(dList.map(function (d) {
var info = d['$'].p.split(','), text = d['_'];
return {
'text': text,
'time': Number(info[0]),
'mode': [undefined, 'R2L', 'R2L', 'R2L', 'BOTTOM', 'TOP'][Number(info[1])],
'size': Number(info[2]),
'color': RRGGBB(parseInt(info[3], 10) & 0xffffff),
'bottom': Number(info[5]) > 0,
// 'create': new Date(Number(info[4])),
// 'pool': Number(info[5]),
// 'sender': String(info[6]),
// 'dmid': Number(info[7]),
};
}))
});
};
// 初始化
var init = function () {
initFont();
};
function main() {
init();
let index = 2;
let totalDanmaku = [];
let maxTime = 0;
function doWork() {
if (index >= process.argv.length) {
var ass = generateASS(setPosition(totalDanmaku), {
'title': "live.list",
'ori': "live.list",
});
process.stdout.write(ass);
} else {
let filename = process.argv[index];
fetchDanmaku(filename, function (danmaku) {
debug('got xml with %d danmaku', danmaku.length);
let thisMaxTime = 0;
for (let d of danmaku) {
let time = d.time + maxTime;
d.time = time;
if (time > thisMaxTime) {
thisMaxTime = time;
}
totalDanmaku.push(d);
}
if (thisMaxTime > maxTime) {
maxTime = thisMaxTime;
}
index++;
setTimeout(doWork, 0);
});
}
}
doWork();
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment