Skip to content

Instantly share code, notes, and snippets.

@rebane2001
Last active May 29, 2023 20:42
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rebane2001/0fd15295aa4e69dd37003d2dda7abd13 to your computer and use it in GitHub Desktop.
Save rebane2001/0fd15295aa4e69dd37003d2dda7abd13 to your computer and use it in GitHub Desktop.
For making ponydubs
<!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/ffmpeg@0.10.1/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(/-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