|
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(); |