Skip to content

Commit

Permalink
Merge pull request #258 from charlielee/exportVideo
Browse files Browse the repository at this point in the history
Export video
  • Loading branch information
charlielee committed Sep 4, 2020
2 parents cd33657 + 9e601a8 commit 317c906
Show file tree
Hide file tree
Showing 21 changed files with 1,330 additions and 591 deletions.
7 changes: 7 additions & 0 deletions InfoPlist.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
NSLocationUsageDescription = "(this app's developers need to add an NSLocationUsageDescription key to an InfoPlist.strings file)";
NSCameraUsageDescription = "This will allow the frames of your animation to be captured.";
CFBundleName = "Boats Animator";
CFBundleDisplayName = "Boats Animator";
NSHumanReadableCopyright = "© 2020 Charlie Lee";
NSBluetoothPeripheralUsageDescription = "(this app's developers need to add an NSBluetoothPeripheralUsageDescription key to an InfoPlist.strings file)";
NSMicrophoneUsageDescription = "(this app's developers need to add an NSMicrophoneUsageDescription key to an InfoPlist.strings file)";
14 changes: 9 additions & 5 deletions app/animator.html
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,15 @@ <h2>
<li>Captured frames will be exported to:
<p id="currentDirectoryName" class="italics">No directory selected</p>
</li>
<li>
<li class="sidebar-opt">
<i class="fa fa-sort-asc fa-rotate-90 sidebar-link-dot"></i>
<span class="sidebar-opt" id="btn-dir-change">Change directory</span>
<span id="btn-dir-change">Change directory</span>
<input id="chooseDirectory" style="display:none;" type="file" nwdirectory />
</li>
<li id="exportVideoSidebarOption" class="sidebar-opt">
<i class="fa fa-sort-asc fa-rotate-90 sidebar-link-dot"></i>
<span id="btn-export-video">Export video</span>
</li>
</ul>
</div>

Expand Down Expand Up @@ -187,9 +191,9 @@ <h2>

<script id="dev-reload-script"></script>
<script src="common/Utils/Utils.js"></script>
<script src="lib/mousetrap.js"></script>
<script src="lib/mousetrap-pause.js"></script>
<script src="../node_modules/mousetrap/mousetrap.js"></script>
<script src="../node_modules/mousetrap/plugins/pause/mousetrap-pause.js"></script>
<script src="js/main.js"></script>
</body>

</html>
</html>
12 changes: 6 additions & 6 deletions app/common/ConfirmDialog/ConfirmDialog.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
(function() {
"use strict";
// Main imports
// const shortcuts = require("../../main/Shortcuts/Shortcuts");
// const menubar = require("../../ui/MenuBar/MenuBar");

// Library imports
var swal = require("../../lib/sweetalert");
var swal = require("sweetalert");

class ConfirmDialog {
/**
Expand All @@ -19,7 +16,10 @@
swalArgs.title = swalArgs.title ? swalArgs.title : "Confirm";
swalArgs.text = swalArgs.text ? swalArgs.text : "Are you sure?";
swalArgs.icon = swalArgs.icon ? swalArgs.icon : "warning";
swalArgs.buttons = swalArgs.buttons ? swalArgs.buttons : true;

if (!("button" in swalArgs)) {
swalArgs.buttons = swalArgs.buttons ? swalArgs.buttons : true;
}

// Pause main shortcuts and menubar items
global.AppShortcuts.remove("main");
Expand All @@ -45,4 +45,4 @@
}

module.exports = ConfirmDialog;
}());
}());
10 changes: 10 additions & 0 deletions app/common/Loader/Loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

class Loader {
static show(message = "", dots = true) {
// Pause main shortcuts and menubar items
global.AppShortcuts.remove("main");
global.AppShortcuts.add("confirm");
global.AppMenuBar.toggleItems();

// See which elements should be displayed
if (!loadingWindow.classList.contains("active")) {
loadingWindow.classList.add("active");
Expand All @@ -30,6 +35,11 @@
}

static hide() {
// Resume main shortcuts and menubar items
global.AppShortcuts.remove("confirm");
global.AppShortcuts.add("main");
global.AppMenuBar.toggleItems();

// Hide loading window
this.isLoading = false;
body.style.cursor = "initial"
Expand Down
21 changes: 21 additions & 0 deletions app/css/animator.css
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,27 @@
width: 100%;
}

#currentVideoExportText {
font-family: monospace;
word-break: break-word;
}

body[data-has-frames="false"] #exportVideoSidebarOption *,
body[data-has-frames="false"] #exportVideoSidebarOption *:hover {
color: #000 !important;
cursor: not-allowed;
text-decoration: none;
}

#exportStatusDialog {
width: 100%;
overflow-y: scroll;
font-family: monospace;
word-break: break-word;
height: 200px;
text-align: left;
}

/* ========== VIDEO PREVIEW ============== */

#preview-area {
Expand Down
9 changes: 6 additions & 3 deletions app/css/common.css
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ aside .sidebar-opt {
cursor: pointer;
}
aside a:hover,
aside .sidebar-opt:hover {
border-bottom: 1.5px solid #d3d3d3;
aside .sidebar-opt span:hover {
text-decoration: underline;
}

aside input,
Expand Down Expand Up @@ -206,4 +206,7 @@ footer li:not(.no-pipe)::after {
-------------------------------- */

/* Fix blue outline around SweetAlert modals */
.swal-overlay { outline: none; }
.swal-overlay { outline: none; }

/* Make custom swal content the same colour as default content */
.swal-content { color: rgba(0,0,0,.64); }
2 changes: 2 additions & 0 deletions app/js/main.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
(function() {
"use strict";
// Main imports
const ExportVideo = require("./main/ExportVideo/ExportVideo");
const PreviewOverlay = require("./main/PreviewOverlay/PreviewOverlay");
const Project = require("./main/Project/Project");
global.AppShortcuts = require("./main/Shortcuts/Shortcuts");
Expand All @@ -24,6 +25,7 @@

// UI initialisation
CaptureOptions.setListeners();
ExportVideo.setListeners();
FrameReelRow.setListeners();
PreviewOverlay.initialise();
WindowManager.setListeners();
Expand Down
236 changes: 236 additions & 0 deletions app/main/ExportVideo/ExportVideo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
(function() {
"use strict";
const path = require("path");

const ConfirmDialog = require("../../common/ConfirmDialog/ConfirmDialog");
const Loader = require("../../common/Loader/Loader");

const DEFAULT_FILE_NAME = "output.mp4";

const btnExportVideo = document.querySelector("#btn-export-video");
const exportVideoSidebarOption = document.querySelector("#exportVideoSidebarOption");

class ExportVideo {
static setListeners() {
let self = this;

// Export video sidebar button
btnExportVideo.addEventListener("click", function() {
if (global.projectInst.currentTake.getTotalFrames() > 0) {
ExportVideo.displayExportVideoDialog();
}
});
self.toggleSidebarOption(false);
}

/**
* Displays the export video dialog box.
*/
static displayExportVideoDialog() {
let saveLocation = global.projectInst.saveDirectory.saveDirLocation;
let defaultExportPath = path.join(saveLocation, DEFAULT_FILE_NAME);

// Html for the export video dialog
let dialogContents = document.createElement("div");
dialogContents.innerHTML = `
<input id="exportLocationInput" type="file" nwsaveas="${DEFAULT_FILE_NAME}" nwworkingdir="${saveLocation}" style="display: none;">
<label>Export location:</label>
<br>
<div id="currentVideoExportText">${defaultExportPath}</div>
<button id="exportLocationBtn">Browse</button>
<br>
<br>
<label for="presetSelect">FFmpeg quality preset:</label>
<br>
<select id="presetSelect">
<option value="veryslow">Very slow</option>
<option value="medium">Medium</option>
<option value="veryfast">Very fast</option>
</select>
<br>
<br>
<label for="customArgumentsInput">FFmpeg arguments:</label>
<br>
<textarea id="customArgumentsInput" rows="5" style="width: 100%;"></textarea>
`;

// Elements
const currentVideoExportText = dialogContents.querySelector("#currentVideoExportText")
const exportLocationInput = dialogContents.querySelector("#exportLocationInput");
const exportLocationBtn = dialogContents.querySelector("#exportLocationBtn");
const presetSelect = dialogContents.querySelector("#presetSelect");
const customArgumentsInput = dialogContents.querySelector("#customArgumentsInput");

// Dialog values
let outputPath = exportLocationInput.value ? exportLocationInput.value : defaultExportPath;
let presetValue = presetSelect.value;

// Export video parameters
let frameLocation = saveLocation;
let frameRate = global.projectInst.frameRate.frameRateValue;

// Load in default FFmpeg arguments
customArgumentsInput.value = ExportVideo.generateFfmpegArguments(outputPath, frameLocation, frameRate, presetValue);

// Event listeners

// Activate hidden input field on button click
exportLocationBtn.addEventListener("click", function() {
exportLocationInput.click();
});

// Listen for the choose save directory dialog being changed
exportLocationInput.addEventListener("change", function() {
if (this.value) {
currentVideoExportText.innerText = this.value;
outputPath = this.value;
customArgumentsInput.value = ExportVideo.generateFfmpegArguments(outputPath, frameLocation, frameRate, presetValue);
}
});

// Listen to the preset value dialog being changed
presetSelect.addEventListener("change", function () {
presetValue = this.value;
customArgumentsInput.value = ExportVideo.generateFfmpegArguments(outputPath, frameLocation, frameRate, presetValue);
});

ConfirmDialog.confirmSet({
title: "Export Video",
text: " ",
icon: " ",
content: dialogContents,
buttons: [true, "Export video"]
})
.then((response) => {
// Confirm the take and render the video if "export video" selected
if (response) {
Loader.show("Confirming take");
global.projectInst.currentTake.confirmTake(false)
.then(() => {
Loader.hide();
ExportVideo.render(customArgumentsInput.value.split(" "), outputPath);
});
}
});

// Auto-click the export location button upon load to prompt user to select an export location
exportLocationInput.click();
}

/**
* Renders a video from the frames in the selected frame location.
* @param {Array} ffmpegArguments An array of ffmpeg arguments to use.
* @param {String} exportPath The path to export video to
*/
static render(ffmpegArguments, exportPath) {
// Spawn an FFmpeg child process
const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path;
const spawn = require('child_process').spawn;
const ffmpeg = spawn(ffmpegPath, ffmpegArguments);

// Build a modal
let exportStatusDialog = document.createElement("div");
exportStatusDialog.setAttribute("id", "exportStatusDialog");
ConfirmDialog.confirmSet({
title: "Exporting video...",
text: " ",
content: exportStatusDialog,
icon: " ",
button: false,
closeOnClickOutside: false,
closeOnEsc: false
});

// Show the status of the export in the modal
// All ffmpeg output goes to stderrdata (see https://stackoverflow.com/questions/35169650/)
ffmpeg.stderr.on('data', function(e) {
console.log("stderrdata", e.toString());
exportStatusDialog.innerHTML = `${e.toString()}<hr>${exportStatusDialog.innerHTML}`;
exportStatusDialog.scrollTo({ top: 0, behavior: 'smooth' });
});

// Stop loader at this point
ffmpeg.on('exit', function (code) {
let exportCompleteDialog = document.createElement("div");

// Display success/error dialog
if (code === 0) {
// Add link to the exported video file
exportCompleteDialog.insertAdjacentHTML('beforeend', `
<p>Video was successfully exported to:</p>
<p><a id="videoExportPathLink" href="#">${exportPath}</a></p>
`);

// Handle clicking said link
exportCompleteDialog.querySelector("#videoExportPathLink").addEventListener("click", () => {
nw.Shell.showItemInFolder(exportPath);
});
} else {
// Display whatever the error is
exportCompleteDialog.insertAdjacentHTML('beforeend', `
<p>An error occurred trying to export the current project to video. Please try again later.</p>
<p>Exit code ${code}.</p>
`);
}

// Show previous error/success output
exportCompleteDialog.appendChild(exportStatusDialog);

ConfirmDialog.confirmSet({
title: code === 0 ? "Success" : "Error",
text: " ",
content: exportCompleteDialog,
icon: code === 0 ? "success" : "error",
buttons: {
cancel: false,
confirm: true
},
});
});
}

/**
* Sets whether the "export video" sidebar item can be selected or not.
* @param {Boolean} status Set to true to hide the item.
*/
static toggleSidebarOption(status) {
exportVideoSidebarOption.classList.toggle("disabled", status);
}

/**
* Generates an array of FFmpeg arguments
* @param {String} exportPath The path to export video to.
* @param {String} frameDirectory The location of the frames to render.
* @param {Number} frameRate The frame rate to use in the export.
* @param {String} preset The rendering preset to use (default: medium).
* @param {Number} startFrameNo The frame to begin rendering from (default: 0 - ie the start).
*/
static generateFfmpegArguments(exportPath, frameDirectory, frameRate, preset = "medium", startFrameNo = 0) {
let endFrameNo = global.projectInst.currentTake.getTotalFrames();
let framePath = path.join(frameDirectory, "frame_%04d.png");

// TODO should the default startFrameNo be 0 or 1?

// The ffmpeg arguments to use
return [
"-y", // Overwrite output file if it already exists
"-framerate", frameRate,
"-start_number", startFrameNo,
"-i", framePath,
"-frames:v", endFrameNo,
"-c:v", "libx264",
"-preset", preset,
"-crf", "17",
"-vf", "format=yuv420p",
exportPath,
"-hide_banner", // Hide FFmpeg library info from output
].join(" ");
}
}

module.exports = ExportVideo;
})();

0 comments on commit 317c906

Please sign in to comment.