Last active
September 4, 2024 22:58
-
-
Save rebane2001/0fd15295aa4e69dd37003d2dda7abd13 to your computer and use it in GitHub Desktop.
For making ponydubs
This file contains hidden or 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
<!doctype html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" | |
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> | |
<meta http-equiv="X-UA-Compatible" content="ie=edge"> | |
<script crossorigin="anonymous" | |
src="https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/ffmpeg.min.js"></script> | |
<title>Pdubhelper4</title> | |
<style> | |
.boxes select, .boxes input { | |
width: 100%; | |
} | |
.boxes select { | |
height: 100%; | |
} | |
video { | |
width: 100%; | |
height: 100%; | |
} | |
.wrapper { | |
display: flex; | |
flex-wrap: wrap; | |
flex-grow: 1; | |
} | |
.resizeable { | |
resize: both; | |
overflow: hidden; | |
border: 2px solid black; | |
} | |
.boxes { | |
width: 20%; | |
flex-grow: 1; | |
margin: 3px; | |
} | |
body { | |
font-family: sans-serif; | |
background-color: black; | |
color: red; | |
} | |
</style> | |
</head> | |
<body> | |
<h1 id="vfdrag">Drag videos folder here</h1> | |
<h1 id="sfdrag">Drag subs folder here</h1> | |
<div class="wrapper"> | |
<div class="resizeable" style="width: 320px; height: 240px;"> | |
<video id="video" width="320" height="240" controls class="resizeable"></video> | |
</div> | |
<div class="boxes"> | |
<input type="text" id="search"> | |
<select id="listbox" width="100" size="16"></select> | |
</div> | |
</div> | |
<button id="mp4btn">Export mp4</button><button id="mp3btn">Export mp3</button><button id="wavbtn">Export wav</button><button id="flacbtn">Export flac</button><span id="exportprogress"></span> | |
<details><summary>Export settings</summary> | |
<div> | |
<label for="exportsecondary">Use secondary audio track:</label> | |
<input type="checkbox" id="exportsecondary" name="exportsecondary"> | |
<br> | |
<label for="exportmp3q">MP3 quality (VBR, 0 is best):</label> | |
<input type="number" id="exportmp3q" name="exportmp3q" min="0" max="10" value="0" step="1"> | |
<br> | |
<label for="exportpadding">Extra "padding" time (seconds):</label> | |
<input type="number" id="exportpadding" name="exportpadding" min="0" max="10" value="0.0" step="0.1"> | |
<br> | |
<label for="exportreencode">Reencode video (slow but better compatibility):</label> | |
<input type="checkbox" id="exportreencode" name="exportreencode"> | |
<br> | |
<label for="exportuseflac">Use external FLAC audio:</label> | |
<input type="checkbox" id="exportuseflac" name="exportuseflac" disabled> | |
<br> | |
<b id="flacdrag">[ Drag FLAC VOX here ]</b> | |
<audio id="flacplayer"></audio> | |
<br> | |
<label for="exportcustomfolder">Download to custom folder:</label> | |
<input type="checkbox" id="exportcustomfolder" name="exportcustomfolder"> | |
<br> | |
<button id="pianobtn">Secret piano button</button> | |
</div> | |
</details> | |
<script> | |
const { createFFmpeg, fetchFile } = FFmpeg; | |
let ffmpeg = createFFmpeg({ log: true }); | |
const vfdrag = document.getElementById("vfdrag"); | |
const sfdrag = document.getElementById("sfdrag"); | |
const flacdrag = document.getElementById("flacdrag"); | |
const search = document.getElementById("search"); | |
const listbox = document.getElementById("listbox"); | |
const video = document.getElementById("video"); | |
const flacplayer = document.getElementById("flacplayer"); | |
const mp4btn = document.getElementById("mp4btn"); | |
const mp3btn = document.getElementById("mp3btn"); | |
const wavbtn = document.getElementById("wavbtn"); | |
const flacbtn = document.getElementById("flacbtn"); | |
const exportProgress = document.getElementById("exportprogress"); | |
const pianobtn = document.getElementById("pianobtn"); | |
const exportSettings = { | |
secondary: document.getElementById("exportsecondary"), | |
mp3q: document.getElementById("exportmp3q"), | |
padding: document.getElementById("exportpadding"), | |
reencode: document.getElementById("exportreencode"), | |
useflac: document.getElementById("exportuseflac"), | |
customfolder: document.getElementById("exportcustomfolder"), | |
}; | |
let customFolderHandle; | |
const videoFiles = {}; | |
const subFiles = {}; | |
const flacFiles = {}; | |
const subs = {}; | |
document.addEventListener("dragenter", event => event.preventDefault()); | |
document.addEventListener("dragover", event => event.preventDefault()); | |
vfdrag.addEventListener("drop", async (event) => { | |
event.preventDefault(); | |
const item = event?.dataTransfer?.items[0]; | |
if (item?.kind !== "file") return; | |
const folderHandle = await item.getAsFileSystemHandle(); | |
console.log(folderHandle) | |
for (const [key, value] of await getAllFilesRecursively(folderHandle)) { | |
videoFiles[filterFilename(key)] = value; | |
} | |
vfdrag.remove(); | |
}); | |
sfdrag.addEventListener("drop", async (event) => { | |
event.preventDefault(); | |
const item = event?.dataTransfer?.items[0]; | |
if (item?.kind !== "file") return; | |
const folderHandle = await item.getAsFileSystemHandle(); | |
for (const [key, value] of await getAllFilesRecursively(folderHandle)) { | |
subFiles[filterFilename(key)] = value; | |
} | |
sfdrag.innerText = "Loading subs..." | |
await loadSubs(); | |
sfdrag.remove(); | |
}); | |
flacdrag.addEventListener("drop", async (event) => { | |
event.preventDefault(); | |
const item = event?.dataTransfer?.items[0]; | |
if (item?.kind !== "file") return; | |
const folderHandle = await item.getAsFileSystemHandle(); | |
for (const [key, value] of await getAllFilesRecursively(folderHandle)) { | |
flacFiles[filterFilename(key)] = value; | |
} | |
flacdrag.remove(); | |
exportSettings.useflac.disabled = false; | |
exportSettings.useflac.checked = true; | |
}); | |
exportSettings.customfolder.addEventListener("change", async () => { | |
if (exportSettings.customfolder.checked) { | |
try { | |
customFolderHandle = await window.showDirectoryPicker({ mode: "readwrite" }); | |
} catch (e) {} | |
if (!customFolderHandle) { | |
exportSettings.customfolder.checked = false; | |
return; | |
} | |
document.querySelector('label[for="exportcustomfolder"]').innerText = `Download to custom folder (${customFolderHandle.name}):`; | |
} | |
}); | |
async function getAllFilesRecursively(folderHandle) { | |
if (folderHandle.kind === 'file') return [[folderHandle.name, folderHandle]]; | |
const files = []; | |
for await (const [key, value] of folderHandle) { | |
if (value.kind === 'directory') | |
files.push(...await getAllFilesRecursively(value)); | |
if (value.kind === 'file') | |
files.push([key, value]); | |
} | |
return files; | |
} | |
function filterFilename(filename) { | |
let filtered = filename.replace(/\.[^/.]+$/, ""); | |
filtered = filtered.replace(/YP-..-/g,''); | |
filtered = filtered.replace(/-V2/g,''); | |
filtered = filtered.replace(/\.v2/g,''); | |
filtered = filtered.replace(/-FIX/g,''); | |
filtered = filtered.replace(/.HoH/g,''); | |
if (filename.endsWith(".flac")) { | |
filtered = filtered.replace(/ .*/g, ''); | |
filtered = "0" + filtered; | |
if (filtered === "0My") filtered = "00x66"; | |
} | |
return filtered; | |
} | |
async function loadSubs() { | |
for (let [key, value] of Object.entries(subFiles)) { | |
subs[key] = []; | |
const splitSubs = (await (await value.getFile()).text()).replaceAll("\r","").split("\n\n"); | |
for (const sub of splitSubs) { | |
if (sub === "" || sub === "\n") continue; | |
const split = sub.split("\n"); | |
const index = split[0]; | |
const start = split[1].split("> ")[0].split(" ")[0]; | |
const end = split[1].split("> ")[1]; | |
const text = split.slice(2).join(" ").replace(/<.*?>/g, ''); | |
subs[key].push({index, start, end, text}); | |
} | |
} | |
} | |
function removeOptions(selectElement) { | |
let i, L = selectElement.options.length - 1; | |
for(i = L; i >= 0; i--) { | |
selectElement.remove(i); | |
} | |
} | |
function srtTime2secs(srtTime) { | |
let total = 0; | |
const split1 = srtTime.replace(".", ",").split(","); | |
const split2 = srtTime.split(":"); | |
total += parseFloat("0." + split1[1]); | |
total += parseFloat(split2[2]); | |
total += parseFloat(split2[1])*60; | |
total += parseFloat(split2[0])*60*60; | |
return total | |
} | |
function secsTime2FFmpeg(secsTime) { | |
const h = String(Math.floor(secsTime / 3600)).padStart(2, "0"); | |
const m = String(Math.floor(secsTime % 3600 / 60)).padStart(2, "0"); | |
const s = String(Math.floor(secsTime % 3600 % 60)).padStart(2, "0"); | |
const ms = (secsTime % 1).toFixed(3).replace("1.000","0.999").replace("0.",""); | |
return `${h}:${m}:${s}.${ms}`; | |
} | |
let lastStartTime = 0; | |
let lastEndTime = 0; | |
listbox.oninput = async () => { | |
const split = listbox.value.split("/"); | |
const width = video.width; | |
const height = video.height; | |
video.onload = () => { | |
video.height = height; | |
video.width = width; | |
}; | |
video.src = URL.createObjectURL((await (videoFiles[split[0]] ?? Object.values(videoFiles)[0]).getFile())); | |
if (flacFiles[split[0]]) | |
flacplayer.src = URL.createObjectURL((await (flacFiles[split[0]]).getFile())); | |
lastStartTime = parseFloat(split[1]); | |
lastEndTime = parseFloat(split[2]); | |
video.currentTime = lastStartTime; | |
await video.play(); | |
}; | |
function synchronizeFlacPlayer(options) { | |
if (flacFiles.length === 0) return; | |
if (!options?.soft || Math.abs(flacplayer.currentTime - video.currentTime) > 0.2) | |
flacplayer.currentTime = video.currentTime; | |
flacplayer.volume = video.volume; | |
if (exportSettings.useflac.checked) { | |
video.muted = true; | |
flacplayer.muted = false; | |
} else { | |
if (flacplayer.muted === false) | |
video.muted = false; | |
flacplayer.muted = true; | |
} | |
if (video.paused !== flacplayer.paused) | |
if (video.paused) { flacplayer.pause(); } else { flacplayer.play(); } | |
} | |
video.onplay = synchronizeFlacPlayer; | |
video.onpause = synchronizeFlacPlayer; | |
video.onseeked = synchronizeFlacPlayer; | |
video.ontimeupdate = () => { synchronizeFlacPlayer({ soft: true }) }; | |
flacplayer.onload = synchronizeFlacPlayer; | |
listbox.onclick = async () => { | |
video.currentTime = lastStartTime; | |
await video.play(); | |
} | |
search.oninput = () => { | |
removeOptions(listbox); | |
const matches = []; | |
const searchEx = new RegExp(search.value, 'i'); | |
for (let [key, sub] of Object.entries(subs)) { | |
for (let line of sub) { | |
if (matches.length > 2048) break; | |
if (searchEx.test(line.text)) | |
matches.push([key, line]); | |
} | |
} | |
for (let match of matches) { | |
const opt = document.createElement('option'); | |
opt.value = match[0] + "/" + srtTime2secs(match[1].start) + "/" + srtTime2secs(match[1].end) + "/" + match[1].text.replace(/\//g, '_'); | |
opt.innerText = `${match[0]}; ${match[1].start.replace("00:","")} -> ${match[1].end.replace("00:","")}; ${match[1].text}`; | |
/* This doesn't work :( | |
const rxmatch = opt.innerHTML.match(searchEx)?.[0] ?? ""; | |
const split = opt.innerHTML.split(rxmatch); | |
opt.innerHTML = split[0] + "<strong>" + rxmatch + "</strong>" + split.slice(1).join(""); | |
*/ | |
listbox.appendChild(opt); | |
} | |
}; | |
mp4btn.addEventListener("click", async (event) => { await exportClip("mp4") }); | |
mp3btn.addEventListener("click", async (event) => { await exportClip("mp3") }); | |
wavbtn.addEventListener("click", async (event) => { await exportClip("wav") }); | |
flacbtn.addEventListener("click", async (event) => { await exportClip("flac") }); | |
async function exportClip(fileType) { | |
if (!ffmpeg.isLoaded()) { | |
exportProgress.innerText = "Initializing ffmpeg..."; | |
await ffmpeg.load(); | |
} | |
const flac = exportSettings.useflac.checked; | |
exportProgress.innerText = "Loading file into memory..."; | |
if (!flac || fileType === "mp4" || !ffmpeg.FS('readdir', '/').includes("video.mp4")) ffmpeg.FS('writeFile', 'video.mp4', await fetchFile(video.src)); | |
if (flac) ffmpeg.FS('writeFile', 'video.flac', await fetchFile(flacplayer.src)); | |
exportProgress.innerText = "Exporting trimmed clip..."; | |
const cutFilename = `cut.${fileType}`; | |
const paddingTime = parseFloat(exportSettings.padding.value); | |
const audioMap = ['-map', `${flac ? '1' : '0'}:a:${exportSettings.secondary.checked ? '1' : '0'}`]; | |
const startTimeArgs = ['-ss', secsTime2FFmpeg(lastStartTime - paddingTime)]; | |
const inputArgs = [...startTimeArgs, '-i', 'video.mp4', ...(flac ? [...startTimeArgs, '-i', 'video.flac'] : []), '-to', secsTime2FFmpeg(lastEndTime - lastStartTime + paddingTime*2)]; | |
const formatArgs = { | |
mp4: ['-map', '0:v', ...audioMap, ...(exportSettings.reencode.checked ? [] : ['-c' + (flac ? ':v' : ''), 'copy'])], | |
mp3: [...audioMap, '-codec:a', 'libmp3lame', '-q:a', String(parseInt(exportSettings.mp3q.value))], | |
wav: [...audioMap], | |
flac: [...audioMap], | |
} | |
await ffmpeg.run(...inputArgs, ...formatArgs[fileType], cutFilename); | |
exportProgress.innerText = ""; | |
await downloadBlob(await ffmpeg.FS('readFile', cutFilename), `${getCurrentClipName()}.${fileType}`); | |
} | |
async function exportPiano() { | |
if (!ffmpeg.isLoaded()) { | |
exportProgress.innerText = "Initializing ffmpeg..."; | |
await ffmpeg.load(); | |
} | |
exportProgress.innerText = "Loading file into memory..."; | |
ffmpeg.FS('writeFile', 'video.mp4', await fetchFile(video.src)); | |
exportProgress.innerText = "Exporting trimmed clip..."; | |
const audioMap = ['-map', `0:a:${exportSettings.secondary.checked ? '1' : '0'}`]; | |
const inputArgs = ['-ss', secsTime2FFmpeg(video.currentTime), '-i', 'video.mp4', '-to', secsTime2FFmpeg(3)]; | |
await ffmpeg.run(...inputArgs, ...audioMap, "piano.wav"); | |
exportProgress.innerText = ""; | |
return await ffmpeg.FS('readFile', "piano.wav"); | |
} | |
function getCurrentClipName() { | |
const split = listbox.value.split("/"); | |
return `${split[0]}_${split[3]}`.substring(0,200); | |
} | |
let piano; | |
pianobtn.addEventListener("click", async () => { | |
if (!piano) { | |
piano = new Audio(); | |
document.addEventListener("keydown", (event) => { | |
const notes = "ZXCVBNMASDFGHJKLQWERTYUIOP1234567890"; | |
piano.playbackRate = (notes.indexOf(event.key.toUpperCase())/notes.length)*3; | |
piano.preservesPitch = false; | |
piano.currentTime = 0; | |
piano.play(); | |
}); | |
} | |
piano.src = URL.createObjectURL(new Blob([await exportPiano("p.wav")])); | |
}); | |
// https://stackoverflow.com/a/62176999/2251833 | |
function downloadURL(data, fileName) { | |
const a = document.createElement('a') | |
a.href = data | |
a.download = fileName | |
document.body.appendChild(a) | |
a.style.display = 'none' | |
a.click() | |
a.remove() | |
} | |
function getLocalFilename(fileName, dupeIndex=0) { | |
const filteredFileName = fileName.replace(/[/\\"<>:|?*]/g,'_'); | |
const splitFileName = filteredFileName.split("."); | |
let fileNameNoExt = splitFileName.slice(0, -1).join('.'); | |
const fileNameExt = splitFileName.at(-1); | |
if (fileNameNoExt.length > 140) | |
fileNameNoExt = fileNameNoExt.slice(0, 140); | |
if (dupeIndex) | |
fileNameNoExt += ` (${dupeIndex})`; | |
return `${fileNameNoExt}.${fileNameExt}`; | |
} | |
async function downloadBlob(data, fileName, mimeType) { | |
const blob = new Blob([data], { | |
type: mimeType | |
}) | |
if (exportSettings.customfolder.checked) { | |
// We don't want to overwrite your precious existing files :) | |
const existingFiles = []; | |
for await (const key of customFolderHandle.keys()) { | |
existingFiles.push(key); | |
} | |
let localFileName = getLocalFilename(fileName); | |
let dupeIndex = 0; | |
while (existingFiles.includes(localFileName)) { | |
dupeIndex++; | |
localFileName = getLocalFilename(fileName, dupeIndex); | |
} | |
const localFileHandle = await customFolderHandle.getFileHandle(localFileName, { create: true }); | |
const localFileWritable = await localFileHandle.createWritable(); | |
await localFileWritable.write(blob); | |
await localFileWritable.close(); | |
return; | |
} | |
const url = window.URL.createObjectURL(blob) | |
downloadURL(url, fileName) | |
setTimeout(() => window.URL.revokeObjectURL(url), 1000) | |
} | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment