Last active
March 8, 2022 13:47
-
-
Save iron9light/155fb046393b504304b54a1e855715e6 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name tingxie | |
// @namespace https://gist.github.com/iron9light/155fb046393b504304b54a1e855715e6 | |
// @version 0.1.21 | |
// @description Ting Xie | |
// @author iron9light | |
// @match https://www.youtube.com/watch* | |
// @grant none | |
// @require https://code.jquery.com/jquery-3.6.0.min.js | |
// @require https://cdnjs.cloudflare.com/ajax/libs/immutable/3.8.2/immutable.min.js | |
// @uploadURL https://gist.githubusercontent.com/iron9light/155fb046393b504304b54a1e855715e6/raw/ | |
// @downloadURL https://gist.githubusercontent.com/iron9light/155fb046393b504304b54a1e855715e6/raw/ | |
// ==/UserScript== | |
(function () { | |
"use strict"; | |
$.when($.ready).then(async function () { | |
console.info("try get caption..."); | |
const caption = await getCaption(); | |
if (caption === null) { | |
console.info("no caption"); | |
return; | |
} | |
const videoController = new VideoController($("video"), caption); | |
await addDOM(videoController); | |
}); | |
function getVideoId() { | |
const searchParams = new URLSearchParams(window.location.search); | |
const videoId = searchParams.get("v"); | |
return videoId; | |
} | |
async function hasEnCaption(videoId) { | |
const url = "https://www.youtube.com/api/timedtext?type=list&v=" + videoId; | |
const xml = await $.get(url).promise(); | |
return $(xml).find("transcript_list > track[lang_code=en]").length > 0; | |
} | |
async function getCaption() { | |
const json = | |
ytplayer.config.args.raw_player_response ?? | |
JSON.parse(ytplayer.config.args.player_response); | |
const captionTracks = | |
json.captions.playerCaptionsTracklistRenderer.captionTracks; | |
const enCaptionTrack = captionTracks.filter( | |
(x) => | |
["en", "en-US", "en-GB"].includes(x.languageCode) && x.kind !== "asr" | |
); | |
if (enCaptionTrack.length === 0) { | |
return null; | |
} | |
const url = enCaptionTrack[0].baseUrl; | |
const captionXml = await $.get(url).promise(); | |
const caption = $(captionXml) | |
.find("transcript > text") | |
.toArray() | |
.map((x) => $(x)) | |
.map( | |
(x) => | |
new TextLine( | |
parseFloat(x.attr("start")), | |
parseFloat(x.attr("dur")), | |
x.text() | |
) | |
) | |
.filter((x) => !x.isSound); | |
if (caption.length === 0) { | |
return null; | |
} | |
return caption; | |
} | |
const _wordSimilarityCache = {}; | |
function wordSimilarity(expected, actual) { | |
if (actual === "") { | |
return 0.0; | |
} | |
if (expected === actual) { | |
return 1.0; | |
} | |
const key = expected + "|" + actual; | |
if (_wordSimilarityCache.hasOwnProperty(key)) { | |
return _wordSimilarityCache[key]; | |
} | |
const distance = levenshteinDistance(actual, expected); | |
const similarity = 1.0 - (distance * 1.0) / expected.length; | |
const value = Math.max(similarity, 0.0); | |
_wordSimilarityCache[key] = value; | |
return value; | |
} | |
function _min(d0, d1, d2, bx, ay) { | |
return d0 < d1 || d2 < d1 | |
? d0 > d2 | |
? d2 + 1 | |
: d0 + 1 | |
: bx === ay | |
? d1 | |
: d1 + 1; | |
} | |
function levenshteinDistance(a, b) { | |
if (a === b) { | |
return 0; | |
} | |
if (a.length > b.length) { | |
var tmp = a; | |
a = b; | |
b = tmp; | |
} | |
var la = a.length; | |
var lb = b.length; | |
while (la > 0 && a.charCodeAt(la - 1) === b.charCodeAt(lb - 1)) { | |
la--; | |
lb--; | |
} | |
var offset = 0; | |
while (offset < la && a.charCodeAt(offset) === b.charCodeAt(offset)) { | |
offset++; | |
} | |
la -= offset; | |
lb -= offset; | |
if (la === 0 || lb < 3) { | |
return lb; | |
} | |
var x = 0; | |
var y; | |
var d0; | |
var d1; | |
var d2; | |
var d3; | |
var dd; | |
var dy; | |
var ay; | |
var bx0; | |
var bx1; | |
var bx2; | |
var bx3; | |
var vector = []; | |
for (y = 0; y < la; y++) { | |
vector.push(y + 1); | |
vector.push(a.charCodeAt(offset + y)); | |
} | |
var len = vector.length - 1; | |
for (; x < lb - 3; ) { | |
bx0 = b.charCodeAt(offset + (d0 = x)); | |
bx1 = b.charCodeAt(offset + (d1 = x + 1)); | |
bx2 = b.charCodeAt(offset + (d2 = x + 2)); | |
bx3 = b.charCodeAt(offset + (d3 = x + 3)); | |
dd = x += 4; | |
for (y = 0; y < len; y += 2) { | |
dy = vector[y]; | |
ay = vector[y + 1]; | |
d0 = _min(dy, d0, d1, bx0, ay); | |
d1 = _min(d0, d1, d2, bx1, ay); | |
d2 = _min(d1, d2, d3, bx2, ay); | |
dd = _min(d2, d3, dd, bx3, ay); | |
vector[y] = dd; | |
d3 = d2; | |
d2 = d1; | |
d1 = d0; | |
d0 = dy; | |
} | |
} | |
for (; x < lb; ) { | |
bx0 = b.charCodeAt(offset + (d0 = x)); | |
dd = ++x; | |
for (y = 0; y < len; y += 2) { | |
dy = vector[y]; | |
vector[y] = dd = _min(dy, d0, dd, bx0, vector[y + 1]); | |
d0 = dy; | |
} | |
} | |
return dd; | |
} | |
function textNormalize(text) { | |
const words = text | |
.toLowerCase() | |
.split(/(\s+|\.\.\.)/) | |
.map((x) => x.replace(/[^a-z0-9']/g, "")) | |
.filter((x) => x.length > 0); | |
return words; | |
} | |
function originTextNormalize(text) { | |
const words = text | |
.replaceAll(/\[[^\[\]]+\]/g, "") | |
.replaceAll(/\([^\(\)]+\)/g, "") | |
.replaceAll(/\<[^\<\>]+\>/g, "") | |
.toLowerCase() | |
.split(/(\s+|\.\.\.)/) | |
.map((x) => x.replace(/[‘’]/u, "'").replace(/[^a-z0-9']/g, "")) | |
.filter((x) => x.length > 0); | |
return words; | |
} | |
function compare(expected, actual) { | |
return _compare(expected, actual, 0, 0, 0.0, Immutable.List(), -1.0); | |
} | |
function _compare( | |
expected, | |
actual, | |
expectedIndex, | |
actualIndex, | |
score, | |
result, | |
threshold | |
) { | |
if (expectedIndex === expected.length) { | |
return { score: score, result: result }; | |
} | |
if (actualIndex === actual.length) { | |
let newResult = result; | |
for (let i = expectedIndex; i < expected.length; ++i) { | |
newResult = newResult.push({ score: 0.0, index: -1 }); | |
} | |
return { score: score, result: newResult }; | |
} | |
if (expected[expectedIndex] === actual[actualIndex]) { | |
const newResult = result.push({ score: 1.0, index: actualIndex }); | |
return _compare( | |
expected, | |
actual, | |
expectedIndex + 1, | |
actualIndex + 1, | |
score + 1.0, | |
newResult, | |
Math.max(score + 1.0, threshold) | |
); | |
} | |
if ( | |
score + | |
Math.min( | |
expected.length - expectedIndex, | |
actual.length - actualIndex | |
) <= | |
threshold | |
) { | |
return { score: -1.0, result: [] }; | |
} | |
let solution; | |
{ | |
const wordScore = wordSimilarity( | |
expected[expectedIndex], | |
actual[actualIndex] | |
); | |
const newResult = result.push({ score: wordScore, index: actualIndex }); | |
solution = _compare( | |
expected, | |
actual, | |
expectedIndex + 1, | |
actualIndex + 1, | |
score + wordScore, | |
newResult, | |
Math.max(score + wordScore, threshold) | |
); | |
} | |
threshold = Math.max(solution.score, threshold); | |
{ | |
// skip one expected word | |
const newResult = result.push({ score: 0.0, index: -1 }); | |
const solution1 = _compare( | |
expected, | |
actual, | |
expectedIndex + 1, | |
actualIndex, | |
score, | |
newResult, | |
threshold | |
); | |
if (solution1.score > solution.score) { | |
solution = solution1; | |
threshold = Math.max(solution.score, threshold); | |
} | |
} | |
{ | |
// skip one actual word | |
const solution2 = _compare( | |
expected, | |
actual, | |
expectedIndex, | |
actualIndex + 1, | |
score, | |
result, | |
threshold | |
); | |
if (solution2.score > solution.score) { | |
solution = solution2; | |
} | |
} | |
return solution; | |
} | |
async function getPanelsEle() { | |
await new Promise((resolve) => setTimeout(resolve, 3000)); | |
//const panelsEle = $('#info'); | |
const panelsEle = $("#panels"); | |
if (panelsEle.length > 0) { | |
return panelsEle; | |
} | |
//await new Promise(resolve => setTimeout(resolve, 1000)); | |
return await getPanelsEle(); | |
} | |
async function addDOM(videoController) { | |
const panelsEle = await getPanelsEle(); | |
const txDivEle = $("<div/>") | |
.css("color", "var(--yt-spec-text-primary)") | |
.css("font-size", "10px"); | |
const controllerDivEle = $("<div/>"); | |
const nextButtonEle = $("<button/>") | |
.text("Next") | |
.on("click", () => videoController.next(true)); | |
const previousButtonEle = $("<button/>") | |
.text("Previous") | |
.on("click", () => videoController.previous()); | |
const resetButtonEle = $("<button/>") | |
.text("Reset") | |
.on("click", () => videoController.reset()); | |
const showButtonEle = $("<button/>").text("Show"); | |
const modeSelectEle = $("<select/>") | |
.append( | |
$("<option/>").val("repeat").text("repeat").attr("selected", "selected") | |
) | |
.append($("<option/>").val("stop").text("stop")) | |
.append($("<option/>").val("continue").text("continue")) | |
.change((ev) => (videoController.mode = ev.target.value)); | |
const autoNextCheckboxEle = $('<input type="checkbox"/>') | |
.attr("id", "autoNext") | |
.prop("checked", true); | |
const autoNextLabelEle = $("<label/>") | |
.text("Auto Next") | |
.attr("for", "autoNext"); | |
controllerDivEle | |
.append(previousButtonEle) | |
.append(nextButtonEle) | |
.append(resetButtonEle) | |
.append(showButtonEle) | |
.append(modeSelectEle) | |
.append(autoNextCheckboxEle) | |
.append(autoNextLabelEle); | |
const textDivEle = $("<div/>").css("font-size", "18px").hide(); | |
const scoreDivEle = $("<div/>"); | |
const inputDivEle = $("<div/>"); | |
const inputTextarea = $("<textarea/>") | |
.css("width", "100%") | |
.css("height", "100px"); | |
let _showMode = 0; | |
function doShow() { | |
switch (_showMode) { | |
case 0: | |
scoreDivEle.find(".inputhint").css("visibility", "hidden"); | |
textDivEle.hide(); | |
break; | |
case 1: | |
scoreDivEle.find(".inputhint").css("visibility", "visible"); | |
break; | |
case 2: | |
textDivEle.show(); | |
break; | |
} | |
} | |
showButtonEle.on("click", function () { | |
_showMode = (_showMode + 1) % 3; | |
doShow(); | |
}); | |
let scoreTextEle; | |
let _previousIndex = null; | |
let _previousInputText = null; | |
function dooninputchange() { | |
const inputText = inputTextarea.val(); | |
if ( | |
videoController.index === _previousIndex && | |
inputText === _previousInputText | |
) { | |
return; | |
} | |
_previousIndex = videoController.index; | |
_previousInputText = inputText; | |
const normalizedInput = textNormalize(inputText); | |
const compareResult = compare(videoController.words, normalizedInput); | |
compareResult.result.forEach((x, i) => { | |
x.i = i; | |
x.word = videoController.words[i]; | |
}); | |
compareResult.score /= videoController.words.length; | |
if (scoreDivEle.find("span").length === 0) { | |
videoController.words.forEach((word) => { | |
$("<span/>") | |
.text("█".repeat(word.length)) | |
//.css('margin-right', '3px') | |
.addClass("inputhint") | |
.appendTo(scoreDivEle); | |
}); | |
$("<span/>").text("█").css("color", "pink").appendTo(scoreDivEle); | |
scoreTextEle = $("<span/>").appendTo(scoreDivEle); | |
doShow(); | |
} | |
const scoreText = Math.floor(compareResult.score * 100); | |
scoreTextEle.text(scoreText); | |
compareResult.result.forEach((x) => | |
$(scoreDivEle.find("span")[x.i]) | |
.css("opacity", x.score) | |
.css("color", x.score === 1.0 ? "green" : "yellow") | |
); | |
if (compareResult.score === 1.0 && autoNextCheckboxEle.is(":checked")) { | |
videoController.next(false); | |
} | |
} | |
function oninputchange() { | |
setTimeout(dooninputchange, 1000); | |
} | |
inputTextarea.on("input", oninputchange); | |
videoController.onReset = () => { | |
_showMode = 0; | |
doShow(); | |
textDivEle.text(videoController.decodedText); | |
scoreDivEle.find("span").remove(); | |
inputTextarea.val(""); | |
oninputchange(); | |
}; | |
inputDivEle.append(inputTextarea); | |
txDivEle | |
.append(controllerDivEle) | |
.append(inputDivEle) | |
.append(textDivEle) | |
.append(scoreDivEle); | |
//panelsEle.before(txDivEle); | |
panelsEle.append(txDivEle); | |
videoController.newTextLine(); | |
oninputchange(); | |
} | |
class TextLine { | |
constructor(start, duration, text) { | |
this.start = start; | |
this.duration = duration; | |
this.end = start + duration; | |
this.text = text; | |
const textArea = document.createElement("textarea"); | |
textArea.innerHTML = this.text; | |
this.decodedText = textArea.value; | |
this.words = originTextNormalize(this.decodedText); | |
} | |
isIn(time) { | |
return time >= this.start && time < this.end; | |
} | |
isBefore(time) { | |
return time < this.start; | |
} | |
isAfter(time) { | |
return time >= this.end; | |
} | |
get isSound() { | |
return this.words.length === 0; | |
} | |
} | |
class VideoController { | |
constructor(video, caption) { | |
this.video = $(video); | |
this.caption = caption; | |
this.index = 0; | |
this.mode = "repeat"; | |
const self = this; | |
this.video.on("timeupdate", () => self.ontimeupdate()); | |
this.onReset = null; | |
} | |
get currentTextLine() { | |
return this.caption[this.index]; | |
} | |
ontimeupdate() { | |
switch (this.mode) { | |
case "repeat": | |
this._ontimeupdateRepeatMode(); | |
break; | |
case "stop": | |
this._ontimeupdateStopMode(); | |
break; | |
case "continue": | |
this._ontimeupdateContinueMode(); | |
break; | |
default: | |
throw "unsupported mode: " + this.mode; | |
} | |
} | |
_ontimeupdateRepeatMode() { | |
const time = this.video[0].currentTime; | |
const textLine = this.currentTextLine; | |
if (textLine.isAfter(time)) { | |
this._setVideoTime(textLine.start); | |
} | |
} | |
_ontimeupdateStopMode() { | |
const time = this.video[0].currentTime; | |
const textLine = this.currentTextLine; | |
if (textLine.isAfter(time)) { | |
this.video[0].pause(); | |
} | |
} | |
_ontimeupdateContinueMode() { | |
const time = this.video[0].currentTime; | |
let i = this.index; | |
while (true) { | |
const textLine = this.caption[i]; | |
if (textLine.isIn(time)) { | |
break; | |
} | |
if (textLine.isBefore(time)) { | |
if (i === 0 || this.caption[i - 1].isAfter(time)) { | |
break; | |
} else { | |
--i; | |
continue; | |
} | |
} else { | |
if (i < this.caption.length) { | |
++i; | |
continue; | |
} else { | |
break; | |
} | |
} | |
} | |
this._search(i); | |
} | |
_search(i) { | |
if (this.index === i) { | |
return; | |
} | |
if (i < 0 || i >= this.caption.length) { | |
throw "index out of range: " + i; | |
} | |
this.index = i; | |
this.newTextLine(); | |
} | |
next(force) { | |
if (this.index < this.caption.length - 1) { | |
this.index++; | |
if (force) { | |
this._setVideoTime(this.currentTextLine.start); | |
} | |
if (this.mode === "stop") { | |
this.video[0].play(); | |
} | |
this.newTextLine(); | |
} | |
} | |
previous() { | |
if (this.index > 0) { | |
this.index--; | |
this._setVideoTime(this.currentTextLine.start); | |
this.newTextLine(); | |
} | |
} | |
reset() { | |
const textLine = this.currentTextLine; | |
this._setVideoTime(textLine.start); | |
this.video[0].play(); | |
} | |
newTextLine() { | |
this.decodedText = this.currentTextLine.decodedText; | |
this.words = this.currentTextLine.words; | |
if (this.onReset !== null) { | |
this.onReset(); | |
} | |
} | |
_setVideoTime(time) { | |
this.video[0].currentTime = Math.max(time - 0.5, 0); | |
} | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
What exactly does this script do?