variabletime/bin/web/js/exporter.js
2023-09-24 18:39:52 +02:00

442 lines
20 KiB
JavaScript

import {
makeEven,
clone,
} from './utils.js';
const FfmpegExporter = function() {
let isFfmpegLoaded = false;
let isFfmpegAttached = () => {
return document.getElementById("ffmpeg.min.js") !== null;
};
const attachFfmpeg = () => {
if (!isFfmpegAttached()) {
// this does not work
// we refuse solving this, by simply attaching the script
// in the template from the beginning
//console.log("FFmpegExport::attachFfmpeg", "not attached yet, doing it");
var s = document.createElement("script");
s.id = "ffmpeg.min.js";
s.type = "application/javascript";
s.src = "/web/ffmpeg_modules/ffmpeg.min.js";
const mom = document.getElementById('body');
mom.appendChild(s);
} else {
//console.log("FFmpegExport::attachFfmpeg", "already attached");
}
};
const createFfmpeg = () => {
return new Promise((resolve) => {
if (!isFfmpegLoaded) {
attachFfmpeg();
const {
createFFmpeg
} = FFmpeg;
this.ffmpeg = createFFmpeg({
log: false
});
window.ffmpeg = this.ffmpeg;
this.ffmpeg.setLogger(({
type,
message
}) => {
if (typeof message === 'string' && message.toLowerCase().indexOf('error') >= 0) {
if(confirm('Oh, there seems to be an error transcoding the video.\n'
+ 'Please either decrease resolution or render Frames instead of mp4.\n'
+ '\n'
+ 'Should we reload the page to restore from the error? Your project should still be there afterwards. If you\'re a bit paranoid (nobody blames you), then you can also first save your project to a zipfile and then reload the page yourself')) {
window.location.reload();
}
}
console.log("FFmpegExport::renderDiary", type, message);
//type can be one of following:
//info: internal workflow debug messages
//fferr: ffmpeg native stderr output
//ffout: ffmpeg native stdout output
});
let texts = [
"We perceive loading bars differently depending on what information they show us. This one for exampl",
"something I always wanted to tell you, is how beautiful you are. ",
"The more you wait, the more it won't happen. Or maybe it will, I don't know. I'm a computer program.",
"Waiting is the rust of the soul. ",
"Waiting is a sin against both the time still to come and the moments one is currently disregarding. ",
"Things may come to those who wait, but only the things left by those who hustle. ",
"There is no great achievement that is not the result of patient working and waiting. ",
"What we are waiting for is not as important as what happens while we are waiting. Trust the process.",
"The worst part of life is waiting. The best part of life is to have someone worth waiting for. ",
"You are not just waiting in vain. There is a purpose behind every delay. And if there is no purpose,",
];
let text = texts[Math.floor(Math.random(0,texts.length))];
ffmpeg.setProgress(({
ratio
}) => {
const percent = ratio * 100;
//let text = "somthing I always wanted to tell you, is how beautiful you are ";
//let text = "The more you wait, the more it won't happen. Or maybe it will, I don't know. I'm a computer program.";
let innerHTML = "|";
for (let i = 0; i < 100; i++) {
if (i < percent) {
innerHTML += text[i%text.length];
} else {
innerHTML += "-";
}
}
innerHTML += "|";
let progress = document.getElementById("export_progress");
progress.innerHTML = innerHTML;
/*
* ratio is a float number between 0 to 1.
*/
});
const loadFfmpeg = async (ffmpeg) => {
await ffmpeg.load();
// mount ffmpeg in oF
if (FS.readdir("/data").indexOf("export") < 0) {
FS.mkdir("/data/export");
}
if (FS.readdir("/data/export").indexOf("frames") < 0) {
FS.mkdir("/data/export/frames");
}
ffmpeg.coreFS().mkdir("/frames");
FS.mount(FS.filesystems.PROXYFS, {
root: "/frames",
fs: ffmpeg.coreFS()
}, "/data/export/frames");
isFfmpegLoaded = true;
resolve();
};
loadFfmpeg(this.ffmpeg);
} else { // already loaded
resolve();
}
});
};
const transcodeVideo = async (finishedCallback) => {
const ffmpeg = this.ffmpeg;
//const of_framesDir = '/data/export/frames';
const ffmpeg_framesDir = '/frames';
// const frameNames = FS.readdir(of_framesDir).splice(2); // remove '.', '..'
// ffmpeg.FS('mkdir', ffmpeg_framesDir);
// for (let i = 0; i < frameNames.length; i++) {
// const frameBuffer = FS.readFile(of_framesDir + "/" + frameNames[i]);
// ffmpeg.FS('writeFile', ffmpeg_framesDir + "/" + frameNames[i], frameBuffer);
// }
const progress_task = document.getElementById('export_progress_task');
const progress = document.getElementById('export_progress');
progress_task.innerHTML = 'transcoding video';
{
let innerHTML = "|";
for (let i = 0; i < 100; i++) {
innerHTML += "-";
}
innerHTML += "|";
progress.innerHTML = innerHTML;
}
await ffmpeg.run('-framerate', '30', '-pattern_type', 'glob', '-i', `${ffmpeg_framesDir}/*.png`, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', 'output.mp4');
progress_task.innerHTML = 'preparing download';
progress.innerHTML = '|----------------------------------------------------------------------------------------------------|'
const data = ffmpeg.FS('readFile', 'output.mp4');
progress.innerHTML = '|::::::::::------------------------------------------------------------------------------------------|'
const buffy = URL.createObjectURL(new Blob([data.buffer], {
type: 'video/mp4'
}));
progress.innerHTML = '|:::::::::::::::::::::::::---------------------------------------------------------------------------|'
// yeey, let's create a timestamp!
let date = new Date();
const offset = date.getTimezoneOffset();
date = new Date(date.getTime() - (offset * 60 * 1000));
progress.innerHTML = '|:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::---------------------------------------|'
const timestamp = date.toISOString();
// phew.. alright, it's not pretty but we did it
const filename = tp.sheet.project.address.projectId + "_" + timestamp + ".mp4";
// now: downloading!
let link = document.createElement("a");
link.href = buffy;
link.download = filename;
link.click();
progress.innerHTML = '|::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::|'
setTimeout(() => {
progress_task.innerHTML = 'idle';
progress.innerHTML = '|----------------------------------------------------------------------------------------------------|'
finishedCallback();
}, 500);
// no video
//const video = document.getElementById('player');
//video.src = URL.createObjectURL(new Blob([data.buffer], {
//type: 'video/mp4'
//}));
//video.style.display = 'flex';
};
// public
this.init = createFfmpeg;
this.transcodeVideo = transcodeVideo;
}
const Exporter = function() {
const exporterDom = document.getElementById('exporter');
const exporterDomChild = document.querySelector('.exporterChild');
const exporterOptionsDom = document.getElementById('exporter_options');
let can_export_mp4 = true;
const options = {
artboard: {
width: 0,
height: 0,
pixelDensity: 1,
userScale: 1,
},
};
const renderDimensions = {
width: 0,
height: 0,
timeScale: 1,
};
const updateArtboardOptions = () => {
options.artboard = {...options.artboard, ...Module.getArtboardProps()};
//options.artboard.width = getArtboard().theatreObject.value.width;
//options.artboard.height = getArtboard().theatreObject.value.height;
options.artboard.pixelDensity = getArtboard().theatreObject.value.pixelDensity;
[...exporterDom.querySelectorAll('.artboard_width')].forEach((e) => {
e.innerHTML = options.artboard.width;
});
[...exporterDom.querySelectorAll('.artboard_height')].forEach((e) => {
e.innerHTML = options.artboard.height;
});
[...exporterDom.querySelectorAll('.artboard_pixelDensity')].forEach((e) => {
e.innerHTML = options.artboard.pixelDensity;
e.remove(); // NOTE: as we ignore pixel density for the export, it's not necessary to show it here
});
};
const setArtboardPropsToRenderDimensions = () => {
const artboardValues = clone(options.artboard);//{...options.artboard, ...renderDimensions};
const densityRatio = renderDimensions.width / options.artboard.width;
//artboardValues.pixelDensity *= densityRatio;
artboardValues.pixelDensity = densityRatio;
const artboardCppProps = getArtboard().values2cppProps(artboardValues);
const currentArtboardValues = Module.getArtboardProps();
if (currentArtboardValues.width !== artboardCppProps.width
|| currentArtboardValues.height !== artboardCppProps.height
|| currentArtboardValues.pixelDensity !== artboardCppProps.pixelDensity) {
window.isRenderDirty = true;
Module.setArtboardProps(artboardCppProps);
}
Module.setTimeScale(renderDimensions.timeScale);
};
const resetArtboardProps = () => {
//const artboardCppProps = getArtboard().values2cppProps(options.artboard);
const artboardValues = getArtboard().theatreObject.value;
const artboardCppProps = getArtboard().values2cppProps(artboardValues);
Module.setArtboardProps(artboardCppProps);
Module.setTimeScale(1.0);
};
const updateRenderDimensions = () => {
const currentDimensions = {
width: options.artboard.width * options.artboard.pixelDensity,
height: options.artboard.height * options.artboard.pixelDensity,
};
const artboardUserScaleLabelDom = document.getElementById('artboard_scale_label');
artboardUserScaleLabelDom.innerHTML = options.artboard.userScale;
const timeScaleLabelDom = document.getElementById('render_timescale_label');
timeScaleLabelDom.innerHTML = renderDimensions.timeScale;
const timelineLength_seconds = window.tp.core.val(window.tp.sheet.sequence.pointer.length);
renderDimensions.width = makeEven(currentDimensions.width * options.artboard.userScale * (1.0 / options.artboard.pixelDensity));
renderDimensions.height = makeEven(currentDimensions.height * options.artboard.userScale * (1.0 / options.artboard.pixelDensity));
[...exporterDom.querySelectorAll('.render_width')].forEach((e) => {
e.innerHTML = renderDimensions.width;
});
[...exporterDom.querySelectorAll('.render_height')].forEach((e) => {
e.innerHTML = renderDimensions.height;
});
[...exporterDom.querySelectorAll('.render_pixels')].forEach((e) => {
// 12345678 => 12.345.678
function addDots(nStr) {
nStr += '';
let x = nStr.split('.');
let x1 = x[0];
let x2 = x.length > 1 ? '.' + x[1] : '';
var rgx = /(\d+)(\d{3})/;
while (rgx.test(x1)) {
x1 = x1.replace(rgx, '$1' + '.' + '$2'); // changed comma to dot here
}
return x1 + x2;
}
e.innerHTML = addDots(`${renderDimensions.width * renderDimensions.height}`);
});
[...exporterDom.querySelectorAll('.render_length')].forEach((e) => {
e.innerHTML = (timelineLength_seconds / renderDimensions.timeScale).toFixed(2);
});
if (renderDimensions.width * renderDimensions.height > 1920 * 1080) {
exporterDom.querySelector('.exporter_dimension_warning').style.display = 'flex';
exporterDom.querySelector('#exporter_button_mp4').disabled = true;
can_export_mp4 = false;
} else {
exporterDom.querySelector('.exporter_dimension_warning').style.display = 'none';
exporterDom.querySelector('#exporter_button_mp4').disabled = false;
can_export_mp4 = true;
}
};
const registerEvents = () => {
const close_button = document.getElementById('exporter_close');
close_button.addEventListener("click", this.close);
const open_button = document.getElementById('exporter_open');
open_button.addEventListener("click", this.open);
const artboardUserScale_input = document.getElementById('artboard_scale');
artboardUserScale_input.addEventListener('change', (event) => {
options.artboard.userScale = event.target.value;
updateRenderDimensions();
});
artboardUserScale_input.addEventListener('input', (event) => {
options.artboard.userScale = event.target.value;
updateRenderDimensions();
});
const timeScale_input = document.getElementById('render_timescale');
timeScale_input.addEventListener('change', (event) => {
renderDimensions.timeScale = event.target.value;
updateRenderDimensions();
});
timeScale_input.addEventListener('input', (event) => {
renderDimensions.timeScale = event.target.value;
updateRenderDimensions();
});
};
window.isRenderDirty = true;
const cancel = (e) => {
e.stopPropagation();
if(confirm("Closing the export window during an active export will cancel the rendering process and reload the page. Is that okay for you?")) {
window.location.reload();
}
};
const resetAfter = () => {
exporterDom.querySelector('#exporter_button_mp4').disabled = !can_export_mp4;
exporterDom.querySelector('#exporter_button_zip').disabled = false;
const close_button = document.getElementById('exporter_close');
close_button.removeEventListener("click", cancel);
close_button.addEventListener("click", this.close);
exporterDom.querySelector('#exporter_render_info').style.display = 'none';
const progress_task = document.getElementById('export_progress_task');
const progress = document.getElementById('export_progress');
progress_task.innerHTML = 'idle';
progress.innerHTML = '|----------------------------------------------------------------------------------------------------|'
};
this.renderFrames = (exportType) => {
exporterDom.querySelector('#exporter_button_mp4').disabled = true;
exporterDom.querySelector('#exporter_button_zip').disabled = true;
const close_button = document.getElementById('exporter_close');
close_button.addEventListener("click", cancel);
close_button.removeEventListener("click", this.close);
exporterDom.querySelector('#exporter_render_info').style.display = 'flex';
setArtboardPropsToRenderDimensions();
if (window.isRenderDirty) {
tp.sheet.sequence.pause();
if (exportType === 'zip') {
window.renderDone = () => {
window.isRenderDirty = false;
const projectName = tp.sheet.project.address.projectId;
Module.exportFramesAsZip(projectName);
// progress is being set in separate cpp thread
// so we reset some things in ofApp.h -> ZipSaver
resetAfter();
};
} else if (exportType === 'mp4') {
window.renderDone = () => {
window.isRenderDirty = false;
this.ffmpegExporter
.init()
.then(() => {
this.ffmpegExporter
.transcodeVideo(resetAfter);
});
};
} else {
window.renderDone = () => {
console.log('rendering done! now.. what?');
window.isRenderDirty = false;
};
}
Module.setRendering(true);
} else {
if (exportType === 'zip') {
const projectName = tp.sheet.project.address.projectId;
Module.exportFramesAsZip(projectName);
// progress is being set in separate cpp thread
// so we reset some things in ofApp.h -> ZipSaver
resetAfter();
} else if (exportType === 'mp4') {
this.ffmpegExporter
.init()
.then(() => {
this.ffmpegExporter
.transcodeVideo(resetAfter);
});
} else {
console.log('now.. what?');
}
}
};
let isInitialized = false;
this.init = () => {
return new Promise((resolve) => {
if (!isInitialized) {
registerEvents();
this.ffmpegExporter
.init()
.then(() => {
resolve();
});
} else {
resolve();
}
});
};
this.ffmpegExporter = new FfmpegExporter();
this.open = () => {
updateArtboardOptions();
updateRenderDimensions();
const renderWidthDom = exporterDom.querySelector('.render_width');
const renderHeightDom = exporterDom.querySelector('.render_height');
// exporterDom.style.display = 'flex';
exporterDom.classList.add('exporterShown');
// exporterDomChild.style.marginBottom = '0vh';
};
this.close = () => {
// exporterDom.style.display = 'none';
exporterDom.classList.remove('exporterShown');
// exporterDomChild.style.marginBottom = '-50vh';
resetArtboardProps();
};
// action
//init();
};
export {
Exporter
}