455 lines
20 KiB
JavaScript
455 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 = () => {
|
|
const artboardValues = clone(Module.getArtboardProps());
|
|
if (typeof artboardValues.backgroundColor === 'object') {
|
|
artboardValues.color = artboardValues.backgroundColor;
|
|
delete artboardValues.backgroundColor;
|
|
}
|
|
options.artboard = {...options.artboard, ...artboardValues};
|
|
//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};
|
|
if (typeof artboardValues.backgroundColor === 'object') {
|
|
artboardValues.color = artboardValues.backgroundColor;
|
|
delete artboardValues.backgroundColor;
|
|
}
|
|
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;
|
|
if (typeof artboardCppProps.color === 'object') {
|
|
artboardCppProps.backgroundColor = artboardCppProps.color;
|
|
delete artboardCppProps.color;
|
|
}
|
|
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
|
|
}
|