fix: audit src/js and add a frame-accurate export path
Two halves of one pass; TODO-FIXES.md lists every finding. The audit fixed, among ~90 items: MP4 export dead-ending and locking the UI behind a stuck "Downloading..." button; height keyframes storing the width; shadow defaults and keyframes storing undefined because fabric's get() is not a path getter; every letter animation writing to the last letter; keyframe times drifting on each drag because data-time was layer-relative on expanded rows; O(n^2 log n) playback, now indexed once per frame; and a save() that rebuilt the record canvas on every edit, now debounced. Exports also lost audio-layer sound outright: MediaRecorder records only the first audio track, so every source now mixes through one AudioContext into one destination. The new half is src/js/render.js, an offline renderer. Real-time capture was the root cause of dropped frames on heavy scenes and smeared video layers: a bare currentTime assignment is async, so drawing straight after it captures the previous frame. Each frame is now seeked, awaited on 'seeked', drawn, and pushed through a VideoEncoder; audio is mixed in one OfflineAudioContext pass and encoded to Opus. webm-writer2.js gained a second Opus track and lost three bugs, including a MAX_CLUSTER_DURATION_MSEC of ~58 days that overflowed the signed 16-bit block timecode past ~32s. Real-time capture remains the fallback: the hand-rolled muxer's output is decoded in a <video> element before being handed over, and any failure returns null so record() falls back transparently. test/webm-muxer.test.js parses the muxer output with an EBML reader in plain Node. It caught the fixed-256-byte header overflow that inspection missed. Not verified in a browser: node --check passes on every script and the muxer test passes, but a running Chrome held the Playwright profile lock, so the manual pass listed at the end of TODO-FIXES.md is still outstanding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+31
-15
@@ -1,15 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<meta charset="utf-8">
|
||||
<!-- Third-party assets live in vendor/ so the app works offline. -->
|
||||
<!-- Run `npm run vendor` to populate that directory. -->
|
||||
<link rel="stylesheet" href="vendor/inter.css">
|
||||
<link rel="stylesheet" href="nice-select.css">
|
||||
<link ref="stylesheet" href="range-slider.min.css">
|
||||
<link rel="stylesheet" href="magic-check.min.css">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/themes/nano.min.css"/>
|
||||
<link rel="stylesheet" href="vendor/pickr-nano.min.css"/>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Motionity - The web-based motion graphics editor for everyone</title>
|
||||
<meta name="title" content="Motionity - The web-based motion graphics editor for everyone">
|
||||
@@ -79,6 +81,18 @@
|
||||
<input class="magic-radio" type="radio" name="radio" value="image" id="image-format">
|
||||
<label for="image-format">Image</label>
|
||||
</div>
|
||||
<div id="framerate-row">
|
||||
<p class="subheader">Frame rate</p>
|
||||
<select id="framerate">
|
||||
<option value="12">12 fps</option>
|
||||
<option value="15">15 fps</option>
|
||||
<option value="24">24 fps</option>
|
||||
<option value="25">25 fps</option>
|
||||
<option value="30" selected>30 fps</option>
|
||||
<option value="50">50 fps</option>
|
||||
<option value="60">60 fps</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="download-real">Download</div>
|
||||
</div>
|
||||
<div id="import-export-modal">
|
||||
@@ -168,7 +182,7 @@
|
||||
<div id="filters">
|
||||
<div id="filters-container">
|
||||
<div id="filters-header">
|
||||
<div id="filters-title">Filters</div>
|
||||
<div class="filters-title">Filters</div>
|
||||
<img src="assets/close.svg" id="filters-close">
|
||||
</div>
|
||||
<select id="filters-list">
|
||||
@@ -183,7 +197,7 @@
|
||||
<option value="Polaroid">Polaroid</option>
|
||||
</select>
|
||||
<hr>
|
||||
<div id="filters-title">Adjustments</div>
|
||||
<div class="filters-title">Adjustments</div>
|
||||
<div id="reset-filters"><img src="assets/repeat.svg"> Reset</div>
|
||||
<div class="filter-row">
|
||||
<th class="name-col">Brightness</th>
|
||||
@@ -216,7 +230,7 @@
|
||||
</th>
|
||||
</div>
|
||||
<hr>
|
||||
<div id="filters-title">Chroma key</div>
|
||||
<div class="filters-title">Chroma key</div>
|
||||
<div class="filter-row">
|
||||
<th class="name-col">Status</th>
|
||||
<th class="value-col">
|
||||
@@ -242,7 +256,7 @@
|
||||
</th>
|
||||
</div>
|
||||
<hr>
|
||||
<div id="filters-title">Stylize</div>
|
||||
<div class="filters-title">Stylize</div>
|
||||
<div class="filter-row">
|
||||
<th class="name-col">Noise</th>
|
||||
<th class="value-col">
|
||||
@@ -289,7 +303,7 @@
|
||||
<div id="keyframe-properties">
|
||||
<div id="easing">
|
||||
<p class="property-title">Keyframe easing</p>
|
||||
<select id="easing">
|
||||
<select id="easing-select">
|
||||
<option value="linear">Linear</option>
|
||||
<option value="easeInQuad">Ease in</option>
|
||||
<option value="easeOutQuad">Ease out</option>
|
||||
@@ -366,16 +380,16 @@
|
||||
<video id="test-video"></video>
|
||||
<input id="emptyInput" value=" " style="opacity:0">
|
||||
<script src="js/libraries/localbase.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.9.6/lottie.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@simonwep/selection-js/lib/selection.min.js"></script>
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
|
||||
<script src="vendor/lottie.min.js"></script>
|
||||
<script src="vendor/selection.min.js"></script>
|
||||
<script src="vendor/jquery.min.js"></script>
|
||||
<script src="js/libraries/sortable.min.js"></script>
|
||||
<script src="js/libraries/range-slider.min.js"></script>
|
||||
<script src="js/libraries/jquery.nice-select.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/pickr.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/460/fabric.min.js"></script>
|
||||
<script src="vendor/pickr.min.js"></script>
|
||||
<script src="vendor/fabric.min.js"></script>
|
||||
<script src="js/libraries/anime.min.js"></script>
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js"></script>
|
||||
<script src="vendor/webfont.js"></script>
|
||||
<script src="js/init.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/align.js"></script>
|
||||
@@ -383,6 +397,8 @@
|
||||
<script src="js/database.js"></script>
|
||||
<script src="js/lottie.js"></script>
|
||||
<script src="js/text.js"></script>
|
||||
<script src="js/webm-writer2.js"></script>
|
||||
<script src="js/render.js"></script>
|
||||
<script src="js/recorder.js"></script>
|
||||
<script src="js/functions.js"></script>
|
||||
<script src="js/events.js"></script>
|
||||
|
||||
+26
-3
@@ -78,6 +78,20 @@ function initLines() {
|
||||
canvas.add(line_v);
|
||||
}
|
||||
|
||||
// Hide the alignment guides after a canvas reload.
|
||||
// They can be missing entirely in projects saved before they existed, so
|
||||
// never dereference them directly.
|
||||
function hideGuides(inst) {
|
||||
const h = inst.getItemById('line_h');
|
||||
const v = inst.getItemById('line_v');
|
||||
if (h) {
|
||||
h.set({ opacity: 0 });
|
||||
}
|
||||
if (v) {
|
||||
v.set({ opacity: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
function alignControls(object, type) {
|
||||
if (type == 'align-top') {
|
||||
object.set(
|
||||
@@ -122,8 +136,10 @@ function alignControls(object, type) {
|
||||
function alignObject() {
|
||||
const type = $(this).attr('id');
|
||||
const object = canvas.getActiveObject();
|
||||
console.log(canvas.getActiveObject().type);
|
||||
if (canvas.getActiveObject().type == 'activeSelection') {
|
||||
if (!object) {
|
||||
return;
|
||||
}
|
||||
if (object.type == 'activeSelection') {
|
||||
const tempselection = canvas.getActiveObject();
|
||||
canvas.discardActiveObject();
|
||||
tempselection._objects.forEach(function (object) {
|
||||
@@ -157,6 +173,8 @@ function alignObject() {
|
||||
);
|
||||
newKeyframe('top', object, currenttime, object.get('top'), true);
|
||||
}
|
||||
canvas.renderAll();
|
||||
save();
|
||||
}
|
||||
$(document).on('click', '.align', alignObject);
|
||||
|
||||
@@ -240,7 +258,12 @@ function centerLines(e) {
|
||||
canvas.renderAll();
|
||||
}
|
||||
}
|
||||
if (obj != e.target && obj != line_h && obj != line_v) {
|
||||
if (
|
||||
obj != e.target &&
|
||||
obj != line_h &&
|
||||
obj != line_v &&
|
||||
obj.visible !== false
|
||||
) {
|
||||
if (
|
||||
obj.get('id') == 'center_h' ||
|
||||
obj.get('id') == 'center_v'
|
||||
|
||||
+78
-36
@@ -1,7 +1,30 @@
|
||||
var workerPath =
|
||||
'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js';
|
||||
// MP4/GIF export transcodes the captured WebM with an asm.js build of ffmpeg.
|
||||
// Packaged builds ship it locally (npm run vendor); a plain checkout falls back
|
||||
// to the public mirror, which needs network access.
|
||||
var FFMPEG_ASM_LOCAL = 'vendor/ffmpeg_asm.js';
|
||||
var FFMPEG_ASM_REMOTE = 'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js';
|
||||
var ffmpegAsmUrlPromise = null;
|
||||
|
||||
function processInWebWorker() {
|
||||
// The worker is built from a blob, so importScripts() needs an absolute URL.
|
||||
function resolveFfmpegAsmUrl() {
|
||||
if (!ffmpegAsmUrlPromise) {
|
||||
ffmpegAsmUrlPromise = fetch(FFMPEG_ASM_LOCAL, { method: 'HEAD' })
|
||||
.then(function (res) {
|
||||
// A catch-all/SPA route answers 200 with HTML; that is not the script.
|
||||
var type = res.headers.get('content-type') || '';
|
||||
var local = res.ok && type.indexOf('text/html') === -1;
|
||||
return local
|
||||
? new URL(FFMPEG_ASM_LOCAL, location.href).href
|
||||
: FFMPEG_ASM_REMOTE;
|
||||
})
|
||||
.catch(function () {
|
||||
return FFMPEG_ASM_REMOTE;
|
||||
});
|
||||
}
|
||||
return ffmpegAsmUrlPromise;
|
||||
}
|
||||
|
||||
function processInWebWorker(workerPath) {
|
||||
var blob = URL.createObjectURL(
|
||||
new Blob(
|
||||
[
|
||||
@@ -21,37 +44,62 @@ function processInWebWorker() {
|
||||
}
|
||||
|
||||
var worker;
|
||||
// The worker is created once and reused, so its "ready" handshake only ever
|
||||
// arrives for the first conversion. Remember it across calls.
|
||||
var workerIsReady = false;
|
||||
|
||||
function convertStreams(videoBlob, setting) {
|
||||
async function convertStreams(videoBlob, setting) {
|
||||
var aab;
|
||||
var buffersReady;
|
||||
var workerReady;
|
||||
var posted;
|
||||
var buffersReady = false;
|
||||
var posted = false;
|
||||
|
||||
function convertFailed(reason) {
|
||||
console.error('Conversion failed: ' + reason);
|
||||
alert(
|
||||
'Sorry, the ' +
|
||||
setting.toUpperCase() +
|
||||
' conversion failed. The WEBM format is always available.'
|
||||
);
|
||||
resetRecordingUI();
|
||||
}
|
||||
|
||||
var fileReader = new FileReader();
|
||||
fileReader.onload = function () {
|
||||
aab = this.result;
|
||||
postMessage();
|
||||
buffersReady = true;
|
||||
if (workerIsReady) postMessage();
|
||||
};
|
||||
fileReader.onerror = function () {
|
||||
convertFailed('could not read the recorded video');
|
||||
};
|
||||
fileReader.readAsArrayBuffer(videoBlob);
|
||||
|
||||
if (!worker) {
|
||||
worker = processInWebWorker();
|
||||
// Safe to await here: workerIsReady is still false, so the FileReader
|
||||
// callback cannot post a command before the worker exists.
|
||||
worker = processInWebWorker(await resolveFfmpegAsmUrl());
|
||||
}
|
||||
worker.onerror = function (e) {
|
||||
convertFailed(e.message || 'worker error');
|
||||
};
|
||||
worker.onmessage = function (event) {
|
||||
var message = event.data;
|
||||
if (message.type == 'ready') {
|
||||
workerReady = true;
|
||||
workerIsReady = true;
|
||||
if (buffersReady) postMessage();
|
||||
} else if (message.type == 'done') {
|
||||
var result = message.data[0];
|
||||
var result = message.data && message.data[0];
|
||||
if (!result || !result.data) {
|
||||
convertFailed('the encoder returned no data');
|
||||
return;
|
||||
}
|
||||
if (setting == 'gif') {
|
||||
var blob = new File([result.data], 'test.gif', {
|
||||
var blob = new File([result.data], 'video.gif', {
|
||||
type: 'image/gif',
|
||||
});
|
||||
PostBlob(blob);
|
||||
} else if (setting == 'mp4') {
|
||||
var blob = new File([result.data], 'test.mp4', {
|
||||
var blob = new File([result.data], 'video.mp4', {
|
||||
type: 'video/mp4',
|
||||
});
|
||||
PostBlob(blob);
|
||||
@@ -59,11 +107,17 @@ function convertStreams(videoBlob, setting) {
|
||||
}
|
||||
};
|
||||
var postMessage = function () {
|
||||
if (posted) return;
|
||||
posted = true;
|
||||
// The recording was made at this rate, so the transcode has to keep it:
|
||||
// a fixed -r would duplicate or drop frames and drift the timing.
|
||||
const fps = getExportFramerate();
|
||||
if (setting == 'gif') {
|
||||
worker.postMessage({
|
||||
type: 'command',
|
||||
arguments: '-i video.webm -r 24 output-10.gif'.split(' '),
|
||||
arguments: ('-i video.webm -r ' + fps + ' output-10.gif').split(
|
||||
' '
|
||||
),
|
||||
files: [
|
||||
{
|
||||
data: new Uint8Array(aab),
|
||||
@@ -74,10 +128,11 @@ function convertStreams(videoBlob, setting) {
|
||||
} else if (setting == 'mp4') {
|
||||
worker.postMessage({
|
||||
type: 'command',
|
||||
arguments:
|
||||
'-i video.webm -c:v mpeg4 -b:v 6400k -strict experimental output.mp4'.split(
|
||||
' '
|
||||
),
|
||||
arguments: (
|
||||
'-i video.webm -c:v mpeg4 -b:v 6400k -r ' +
|
||||
fps +
|
||||
' -strict experimental output.mp4'
|
||||
).split(' '),
|
||||
files: [
|
||||
{
|
||||
data: new Uint8Array(aab),
|
||||
@@ -97,22 +152,9 @@ function PostBlob(blob) {
|
||||
a.download = blob.name || 'video';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
recording = false;
|
||||
currenttime = 0;
|
||||
animate(false, 0);
|
||||
$('#seekbar').offset({
|
||||
left:
|
||||
offset_left +
|
||||
$('#inner-timeline').offset().left +
|
||||
currenttime / timelinetime,
|
||||
});
|
||||
canvas.renderAll();
|
||||
resizeCanvas();
|
||||
if (background_audio != false) {
|
||||
background_audio.pause();
|
||||
background_audio = new Audio(background_audio.src);
|
||||
}
|
||||
$('#download-real').html('Download');
|
||||
$('#download-real').removeClass('downloading');
|
||||
updateRecordCanvas();
|
||||
document.body.removeChild(a);
|
||||
window.setTimeout(function () {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 60000);
|
||||
resetRecordingUI();
|
||||
}
|
||||
|
||||
+113
-35
@@ -88,15 +88,24 @@ function checkDB() {
|
||||
} else {
|
||||
loadProject();
|
||||
}
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.error('Could not open the local project database', e);
|
||||
});
|
||||
}
|
||||
|
||||
// Automatically save project (locally)
|
||||
function autoSave() {
|
||||
async function autoSave() {
|
||||
if (checkstatus) {
|
||||
canvas.clipPath = null;
|
||||
objects.forEach(async function (object) {
|
||||
// Sequential, not forEach(async): toDatalessJSON below must not run
|
||||
// while filters are still being stripped off the objects.
|
||||
for (const object of objects) {
|
||||
var obj = canvas.getItemById(object.id);
|
||||
if (!obj) {
|
||||
object.filters = [];
|
||||
continue;
|
||||
}
|
||||
if (obj.filters) {
|
||||
if (obj.filters.length > 0) {
|
||||
object.filters = [];
|
||||
@@ -174,7 +183,7 @@ function autoSave() {
|
||||
} else {
|
||||
object.filters = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
const inst = canvas.toDatalessJSON([
|
||||
'volume',
|
||||
'audioSrc',
|
||||
@@ -250,6 +259,9 @@ function autoSave() {
|
||||
activepreset: activepreset,
|
||||
width: artboard.width,
|
||||
height: artboard.height,
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.error('Autosave failed', e);
|
||||
});
|
||||
objects.forEach(function (object) {
|
||||
replaceSource(canvas.getItemById(object.id), canvas);
|
||||
@@ -289,17 +301,24 @@ function loadProject() {
|
||||
currenttime = 0;
|
||||
canvas.clipPath = null;
|
||||
canvas.clear();
|
||||
fabric.filterBackend = webglBackend;
|
||||
if (webglBackend) {
|
||||
fabric.filterBackend = webglBackend;
|
||||
}
|
||||
f = fabric.Image.filters;
|
||||
canvas.loadFromJSON(JSON.parse(project.canvas), function () {
|
||||
canvas.clipPath = artboard;
|
||||
canvas.getItemById('line_h').set({ opacity: 0 });
|
||||
canvas.getItemById('line_v').set({ opacity: 0 });
|
||||
hideGuides(canvas);
|
||||
canvas.renderAll();
|
||||
$('.object-props').remove();
|
||||
$('.layer').remove();
|
||||
objects.forEach(function (object) {
|
||||
var animatethis = false;
|
||||
if (!object.animate) {
|
||||
object.animate = [];
|
||||
}
|
||||
if (!canvas.getItemById(object.id)) {
|
||||
return;
|
||||
}
|
||||
if (object.animate.length > 5) {
|
||||
if (isSameSet(object.animate, props)) {
|
||||
animatethis = true;
|
||||
@@ -331,6 +350,9 @@ function loadProject() {
|
||||
}
|
||||
});
|
||||
keyframes.forEach(function (keyframe) {
|
||||
if (!canvas.getItemById(keyframe.id)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
keyframe.name != 'top' &&
|
||||
keyframe.name != 'scaleY' &&
|
||||
@@ -510,12 +532,18 @@ function deleteAsset(key) {
|
||||
.doc({ key: key })
|
||||
.get()
|
||||
.then((asset) => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
var temp = files.filter((x) => x.file == asset.src);
|
||||
if (temp.length > 0) {
|
||||
temp.forEach(function (file) {
|
||||
deleteObject(canvas.getItemById(file.name));
|
||||
const object = canvas.getItemById(file.name);
|
||||
if (object) {
|
||||
deleteObject(object);
|
||||
}
|
||||
files = $.grep(files, function (a) {
|
||||
return a != file;
|
||||
return a !== file;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -534,14 +562,26 @@ function deleteAsset(key) {
|
||||
});
|
||||
}
|
||||
|
||||
function getAssets() {
|
||||
function getAssets(attempt) {
|
||||
attempt = attempt || 0;
|
||||
db.collection('assets')
|
||||
.get()
|
||||
.then((assets) => {
|
||||
// Sometimes the assets aren't ready when importing, really annoying
|
||||
// Sometimes the assets aren't ready when importing, really annoying.
|
||||
// Retry on a timer with a hard cap - a bare synchronous re-call spins
|
||||
// the CPU forever when the collection never resolves.
|
||||
if (assets === undefined) {
|
||||
getAssets();
|
||||
if (attempt < 20) {
|
||||
window.setTimeout(function () {
|
||||
getAssets(attempt + 1);
|
||||
}, 250);
|
||||
} else {
|
||||
console.error('Could not read assets from the local database');
|
||||
}
|
||||
} else if (assets.length > 0) {
|
||||
// Rebuild rather than append: getAssets() runs again after an import
|
||||
uploaded_images = [];
|
||||
uploaded_videos = [];
|
||||
assets.forEach(function (asset) {
|
||||
if (asset.type == 'image') {
|
||||
uploaded_images.push({
|
||||
@@ -562,6 +602,9 @@ function getAssets() {
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.error('Could not read assets', e);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -570,10 +613,20 @@ function readTextFile(file, callback) {
|
||||
rawFile.overrideMimeType('application/json');
|
||||
rawFile.open('GET', file, true);
|
||||
rawFile.onreadystatechange = function () {
|
||||
if (rawFile.readyState === 4 && rawFile.status == '200') {
|
||||
callback(rawFile.responseText);
|
||||
if (rawFile.readyState === 4) {
|
||||
// A blob: URL resolves with status 0
|
||||
if (rawFile.status == 200 || rawFile.status === 0) {
|
||||
callback(rawFile.responseText);
|
||||
} else {
|
||||
alert('Could not read the file');
|
||||
$('#import-project span').html('Import');
|
||||
}
|
||||
}
|
||||
};
|
||||
rawFile.onerror = function () {
|
||||
alert('Could not read the file');
|
||||
$('#import-project span').html('Import');
|
||||
};
|
||||
rawFile.send(null);
|
||||
}
|
||||
|
||||
@@ -582,10 +635,20 @@ async function importProject(e) {
|
||||
var file = e.target.files[0];
|
||||
var path = (window.URL || window.webkitURL).createObjectURL(file);
|
||||
readTextFile(path, function (text) {
|
||||
var data = JSON.parse(text);
|
||||
var data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (e) {
|
||||
data = null;
|
||||
}
|
||||
if (!data || !Array.isArray(data.project) || data.project.length == 0) {
|
||||
alert('Wrong file type');
|
||||
$('#import-project span').html('Import');
|
||||
return;
|
||||
}
|
||||
delete data.project[0].id;
|
||||
if (data.project.length > 0) {
|
||||
if (data.assets.length > 0) {
|
||||
{
|
||||
if (Array.isArray(data.assets) && data.assets.length > 0) {
|
||||
data.assets.forEach(function (asset) {
|
||||
delete asset.id;
|
||||
db.collection('assets').add(asset);
|
||||
@@ -598,10 +661,14 @@ async function importProject(e) {
|
||||
$('#import-project span').html('Import');
|
||||
hideModals();
|
||||
loadProject();
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.error('Import failed', e);
|
||||
alert('Import failed');
|
||||
$('#import-project span').html('Import');
|
||||
});
|
||||
} else {
|
||||
alert('Wrong file type');
|
||||
}
|
||||
window.URL.revokeObjectURL(path);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -619,18 +686,24 @@ function exportProject() {
|
||||
.get()
|
||||
.then((assets) => {
|
||||
var exportarr = { project: project, assets: assets };
|
||||
$('<a />', {
|
||||
download: 'data.json',
|
||||
href:
|
||||
'data:application/json,' +
|
||||
encodeURIComponent(JSON.stringify(exportarr)),
|
||||
})
|
||||
.appendTo('body')
|
||||
.click(function () {
|
||||
$(this).remove();
|
||||
$('#export-project span').html('Export');
|
||||
})[0]
|
||||
.click();
|
||||
// Blob, not a data: URL - projects with media blow past the
|
||||
// maximum URL length.
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([JSON.stringify(exportarr)], {
|
||||
type: 'application/json',
|
||||
})
|
||||
);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = 'data.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.setTimeout(function () {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 60000);
|
||||
$('#export-project span').html('Export');
|
||||
});
|
||||
} else {
|
||||
alert('Empty project');
|
||||
@@ -649,11 +722,16 @@ function clearProject() {
|
||||
'Are you sure you want to clear this project? This action cannot be undone.'
|
||||
)
|
||||
) {
|
||||
db.collection('projects').delete();
|
||||
db.collection('assets').delete();
|
||||
window.setTimeout(function () {
|
||||
location.reload();
|
||||
}, 1000);
|
||||
Promise.all([
|
||||
db.collection('projects').delete(),
|
||||
db.collection('assets').delete(),
|
||||
])
|
||||
.catch(function (e) {
|
||||
console.error('Could not clear the project', e);
|
||||
})
|
||||
.then(function () {
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
hideMore();
|
||||
}
|
||||
|
||||
+144
-125
@@ -1,17 +1,79 @@
|
||||
// Remember the last valid crop rectangle so it can be restored if the user
|
||||
// drags it outside the image.
|
||||
function updateCropBounds() {
|
||||
const cropUI = canvas.getItemById('crop');
|
||||
if (!cropUI || !cropobj) {
|
||||
return;
|
||||
}
|
||||
if (cropUI.isContainedWithinObject(cropobj)) {
|
||||
cropleft = cropUI.get('left');
|
||||
croptop = cropUI.get('top');
|
||||
cropscalex = cropUI.get('scaleX');
|
||||
cropscaley = cropUI.get('scaleY');
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-to-reorder for the layer list. html5sortable only wires up the
|
||||
// children present when it runs, so this is re-run every time a layer is
|
||||
// added. The sortstop handler is bound only once.
|
||||
let layerSortBound = false;
|
||||
function initLayerSortable() {
|
||||
const list = document.getElementById('layer-inner-list');
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
const sortableList = sortable(list, {
|
||||
handle: '.layer-handle',
|
||||
customDragImage: (draggedElement, elementOffset, event) => {
|
||||
return {
|
||||
element: document.getElementById('nothing'),
|
||||
posX: event.pageX - elementOffset.left,
|
||||
posY: event.pageY - elementOffset.top,
|
||||
};
|
||||
},
|
||||
})[0];
|
||||
|
||||
// Re-initializing marks every handle draggable again, so locked layers
|
||||
// have to be opted back out.
|
||||
$('#layer-inner-list .layer').each(function () {
|
||||
if ($(this).find('.lock').hasClass('locked')) {
|
||||
$(this).find('.layer-handle').attr('draggable', false);
|
||||
}
|
||||
});
|
||||
|
||||
if (layerSortBound) {
|
||||
return;
|
||||
}
|
||||
layerSortBound = true;
|
||||
sortableList.addEventListener('sortupdate', function () {
|
||||
syncTimelineOrder();
|
||||
orderLayers();
|
||||
});
|
||||
}
|
||||
|
||||
// Re-order the timeline rows to match the layer list
|
||||
function syncTimelineOrder() {
|
||||
const timeline = document.getElementById('inner-timeline');
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
$('#layer-inner-list .layer').each(function () {
|
||||
const row = document.getElementById(
|
||||
$(this).attr('data-object')
|
||||
);
|
||||
if (row) {
|
||||
timeline.appendChild(row);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
// An object is being moved in the canvas
|
||||
canvas.on('object:moving', function (e) {
|
||||
e.target.hasControls = false;
|
||||
centerLines(e);
|
||||
if (cropping) {
|
||||
if (
|
||||
canvas.getItemById('crop').isContainedWithinObject(cropobj)
|
||||
) {
|
||||
cropleft = canvas.getItemById('crop').get('left');
|
||||
croptop = canvas.getItemById('crop').get('top');
|
||||
cropscalex = canvas.getItemById('crop').get('scaleX');
|
||||
cropscaley = canvas.getItemById('crop').get('scaleY');
|
||||
}
|
||||
updateCropBounds();
|
||||
crop(canvas.getItemById('cropped'));
|
||||
} else if (
|
||||
lockmovement &&
|
||||
@@ -39,14 +101,7 @@ $(document).ready(function () {
|
||||
e.target.hasControls = false;
|
||||
centerLines(e);
|
||||
if (cropping) {
|
||||
if (
|
||||
canvas.getItemById('crop').isContainedWithinObject(cropobj)
|
||||
) {
|
||||
cropleft = canvas.getItemById('crop').get('left');
|
||||
croptop = canvas.getItemById('crop').get('top');
|
||||
cropscalex = canvas.getItemById('crop').get('scaleX');
|
||||
cropscaley = canvas.getItemById('crop').get('scaleY');
|
||||
}
|
||||
updateCropBounds();
|
||||
crop(canvas.getItemById('cropped'));
|
||||
}
|
||||
});
|
||||
@@ -56,24 +111,15 @@ $(document).ready(function () {
|
||||
e.target.hasControls = false;
|
||||
centerLines(e);
|
||||
if (cropping) {
|
||||
if (
|
||||
canvas.getItemById('crop').isContainedWithinObject(cropobj)
|
||||
) {
|
||||
cropleft = canvas.getItemById('crop').get('left');
|
||||
croptop = canvas.getItemById('crop').get('top');
|
||||
cropscalex = canvas.getItemById('crop').get('scaleX');
|
||||
cropscaley = canvas.getItemById('crop').get('scaleY');
|
||||
}
|
||||
updateCropBounds();
|
||||
crop(canvas.getItemById('cropped'));
|
||||
}
|
||||
});
|
||||
|
||||
// An object is being rotated in the canvas
|
||||
canvas.on('object:rotating', function (e) {
|
||||
if (e.e.shiftKey) {
|
||||
canvas.getActiveObject().snapAngle = 15;
|
||||
} else {
|
||||
canvas.getActiveObject().snapAngle = 0;
|
||||
if (canvas.getActiveObject()) {
|
||||
canvas.getActiveObject().snapAngle = e.e.shiftKey ? 15 : 0;
|
||||
}
|
||||
e.target.hasControls = false;
|
||||
});
|
||||
@@ -82,8 +128,10 @@ $(document).ready(function () {
|
||||
canvas.on('object:modified', function (e) {
|
||||
e.target.hasControls = true;
|
||||
if (!editinggroup && !cropping) {
|
||||
canvas.getActiveObject().lockMovementX = false;
|
||||
canvas.getActiveObject().lockMovementY = false;
|
||||
if (canvas.getActiveObject()) {
|
||||
canvas.getActiveObject().lockMovementX = false;
|
||||
canvas.getActiveObject().lockMovementY = false;
|
||||
}
|
||||
canvas.renderAll();
|
||||
if (e.target.type == 'activeSelection') {
|
||||
const tempselection = canvas.getActiveObject();
|
||||
@@ -193,8 +241,12 @@ $(document).ready(function () {
|
||||
this.setViewportTransform(this.viewportTransform);
|
||||
this.isDragging = false;
|
||||
this.selection = true;
|
||||
line_h.opacity = 0;
|
||||
line_v.opacity = 0;
|
||||
if (line_h) {
|
||||
line_h.opacity = 0;
|
||||
}
|
||||
if (line_v) {
|
||||
line_v.opacity = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Detect mouse over canvas (for dragging objects from the library)
|
||||
@@ -214,7 +266,9 @@ $(document).ready(function () {
|
||||
canvas.on('mouse:out', function (e) {
|
||||
overCanvas = false;
|
||||
if (wip) {
|
||||
e.target.hasControls = true;
|
||||
if (e.target) {
|
||||
e.target.hasControls = true;
|
||||
}
|
||||
canvas.discardActiveObject();
|
||||
wip = false;
|
||||
canvas.renderAll();
|
||||
@@ -320,13 +374,16 @@ $(document).ready(function () {
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
// Redo
|
||||
if (e.which === 90 && (e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
undoRedo(redo, undo, redoarr, undoarr);
|
||||
}
|
||||
// Undo
|
||||
// Redo / undo (shift decides which; never both in one keypress)
|
||||
if (e.which === 90 && (e.ctrlKey || e.metaKey)) {
|
||||
undoRedo(undo, redo, undoarr, redoarr);
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
if (redo.length >= 1) {
|
||||
undoRedo(redo, undo, redoarr, undoarr);
|
||||
}
|
||||
} else if (undo.length >= 1) {
|
||||
undoRedo(undo, redo, undoarr, redoarr);
|
||||
}
|
||||
}
|
||||
// Duplicate object
|
||||
if (e.which === 68 && (e.ctrlKey || e.metaKey)) {
|
||||
@@ -353,51 +410,30 @@ $(document).ready(function () {
|
||||
if (e.keyCode === 13 && editingproject) {
|
||||
saveProjectName();
|
||||
}
|
||||
// Left arrow key (move object to the left)
|
||||
if (e.keyCode === 37 && canvas.getActiveObject()) {
|
||||
var obj = canvas.getActiveObject();
|
||||
var step = 2;
|
||||
// Arrow keys nudge the selection
|
||||
if (
|
||||
e.keyCode >= 37 &&
|
||||
e.keyCode <= 40 &&
|
||||
canvas.getActiveObject() &&
|
||||
!canvas.getActiveObject().isEditing &&
|
||||
!focus &&
|
||||
!editinglayer &&
|
||||
!editingproject
|
||||
) {
|
||||
const obj = canvas.getActiveObject();
|
||||
// Bigger step if shift is down
|
||||
if (e.shiftKey) {
|
||||
step = 7;
|
||||
const step = e.shiftKey ? 7 : 2;
|
||||
if (e.keyCode === 37) {
|
||||
obj.left = obj.left - step;
|
||||
} else if (e.keyCode === 38) {
|
||||
obj.top = obj.top - step;
|
||||
} else if (e.keyCode === 39) {
|
||||
obj.left = obj.left + step;
|
||||
} else {
|
||||
obj.top = obj.top + step;
|
||||
}
|
||||
obj.left = obj.left - step;
|
||||
canvas.renderAll();
|
||||
autoKeyframe(obj, { action: 'drag' }, false);
|
||||
}
|
||||
// Up arrow key (move object up)
|
||||
if (e.keyCode === 38 && canvas.getActiveObject()) {
|
||||
var obj = canvas.getActiveObject();
|
||||
var step = 2;
|
||||
// Bigger step if shift is down
|
||||
if (e.shiftKey) {
|
||||
step = 7;
|
||||
}
|
||||
obj.top = obj.top - step;
|
||||
canvas.renderAll();
|
||||
autoKeyframe(obj, { action: 'drag' }, false);
|
||||
}
|
||||
// Right arrow key (move object to the right)
|
||||
if (e.keyCode === 39 && canvas.getActiveObject()) {
|
||||
var obj = canvas.getActiveObject();
|
||||
var step = 2;
|
||||
// Bigger step if shift is down
|
||||
if (e.shiftKey) {
|
||||
step = 7;
|
||||
}
|
||||
obj.left = obj.left + step;
|
||||
canvas.renderAll();
|
||||
autoKeyframe(obj, { action: 'drag' }, false);
|
||||
}
|
||||
// Down arrow key (move object down)
|
||||
if (e.keyCode === 40 && canvas.getActiveObject()) {
|
||||
var obj = canvas.getActiveObject();
|
||||
var step = 2;
|
||||
// Bigger step if shift is down
|
||||
if (e.shiftKey) {
|
||||
step = 7;
|
||||
}
|
||||
obj.top = obj.top + step;
|
||||
// Without this the selection box stays where the object used to be
|
||||
obj.setCoords();
|
||||
canvas.renderAll();
|
||||
autoKeyframe(obj, { action: 'drag' }, false);
|
||||
}
|
||||
@@ -406,7 +442,7 @@ $(document).ready(function () {
|
||||
if (
|
||||
e.keyCode === 221 &&
|
||||
canvas.getActiveObjects() &&
|
||||
e.metaKey
|
||||
(e.metaKey || e.ctrlKey)
|
||||
) {
|
||||
if (canvas.getActiveObjects().length == 1) {
|
||||
var obj = canvas.getActiveObject();
|
||||
@@ -434,7 +470,7 @@ $(document).ready(function () {
|
||||
if (
|
||||
e.keyCode === 219 &&
|
||||
canvas.getActiveObjects() &&
|
||||
e.metaKey
|
||||
(e.metaKey || e.ctrlKey)
|
||||
) {
|
||||
if (canvas.getActiveObjects().length == 1) {
|
||||
var obj = canvas.getActiveObject();
|
||||
@@ -545,25 +581,24 @@ $(document).ready(function () {
|
||||
|
||||
// Copy event
|
||||
window.addEventListener('copy', function (e) {
|
||||
// Selecting keyframes clears the canvas selection, so there is often no
|
||||
// active object at all here - never dereference it unguarded.
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (activeObject && activeObject.isEditing) {
|
||||
return;
|
||||
}
|
||||
// Copy selected object
|
||||
if (
|
||||
canvas.getActiveObject() &&
|
||||
shiftkeys.length == 0 &&
|
||||
!canvas.getActiveObject().isEditing
|
||||
) {
|
||||
if (activeObject && shiftkeys.length == 0) {
|
||||
var emptyInp = document.getElementById('emptyInput');
|
||||
emptyInp.select();
|
||||
emptyInp.focus();
|
||||
setTimeout(function () {
|
||||
document.execCommand('copy');
|
||||
}, 0);
|
||||
clipboard = canvas.getActiveObject();
|
||||
clipboard = activeObject;
|
||||
cliptype = 'object';
|
||||
// Copy selected keyframe(s)
|
||||
} else if (
|
||||
shiftkeys.length > 0 &&
|
||||
!canvas.getActiveObject().isEditing
|
||||
) {
|
||||
} else if (shiftkeys.length > 0) {
|
||||
var emptyInp = document.getElementById('emptyInput');
|
||||
emptyInp.select();
|
||||
emptyInp.focus();
|
||||
@@ -580,7 +615,9 @@ $(document).ready(function () {
|
||||
e.name == drag.attr('data-property')
|
||||
);
|
||||
});
|
||||
clipboard.push(keyarr[0]);
|
||||
if (keyarr.length > 0) {
|
||||
clipboard.push(keyarr[0]);
|
||||
}
|
||||
});
|
||||
cliptype = 'keyframe';
|
||||
}
|
||||
@@ -598,7 +635,9 @@ $(document).ready(function () {
|
||||
} else {
|
||||
for (var i = 0; i < imgs.length; i++) {
|
||||
if (imgs[i].type.indexOf('image') == -1) continue;
|
||||
var imgObj = imgs[i].getAsFile();
|
||||
// `let` so each async thumbnail callback keeps its own file
|
||||
let imgObj = imgs[i].getAsFile();
|
||||
if (!imgObj) continue;
|
||||
if (imgObj.size / 1024 / 1024 <= 10) {
|
||||
createThumbnail(imgObj, 250).then(function (data) {
|
||||
saveFile(
|
||||
@@ -803,27 +842,7 @@ $(document).ready(function () {
|
||||
syncScrollHoz($('#timeline'), $('#seekarea'));
|
||||
|
||||
// Initialize layer sorting
|
||||
sortable('#layer-inner-list', {
|
||||
customDragImage: (draggedElement, elementOffset, event) => {
|
||||
return {
|
||||
element: document.getElementById('nothing'),
|
||||
posX: event.pageX - elementOffset.left,
|
||||
posY: event.pageY - elementOffset.top,
|
||||
};
|
||||
},
|
||||
})[0].addEventListener('sortstop', function (e) {
|
||||
const id = $(e.detail.item).attr('data-object');
|
||||
const previd = $(e.detail.item).prev().attr('data-object');
|
||||
if ($('.sortable-dragging').length == 1) {
|
||||
$('.sortable-dragging').remove();
|
||||
if (previd == undefined) {
|
||||
$('#inner-timeline').prepend($('#' + id));
|
||||
} else {
|
||||
$('#' + id).insertAfter($('#' + previd));
|
||||
}
|
||||
orderLayers();
|
||||
}
|
||||
});
|
||||
initLayerSortable();
|
||||
|
||||
// Initialize dropdown for keyframe easing
|
||||
$('#easing select').niceSelect();
|
||||
@@ -936,8 +955,8 @@ $(document).ready(function () {
|
||||
onmove: function (x) {
|
||||
if (canvas.getActiveObject()) {
|
||||
var obj = canvas.getActiveObject();
|
||||
if (obj.filters.find((x) => x.type == 'RemoveColor')) {
|
||||
obj.filters.find((x) => x.type == 'RemoveColor').distance =
|
||||
if (obj.filters.find((i) => i.type == 'RemoveColor')) {
|
||||
obj.filters.find((i) => i.type == 'RemoveColor').distance =
|
||||
x / 100;
|
||||
}
|
||||
obj.applyFilters();
|
||||
@@ -964,8 +983,8 @@ $(document).ready(function () {
|
||||
onmove: function (x) {
|
||||
if (canvas.getActiveObject()) {
|
||||
var obj = canvas.getActiveObject();
|
||||
if (obj.filters.find((x) => x.type == 'Noise')) {
|
||||
obj.filters.find((x) => x.type == 'Noise').noise = x;
|
||||
if (obj.filters.find((i) => i.type == 'Noise')) {
|
||||
obj.filters.find((i) => i.type == 'Noise').noise = x;
|
||||
} else {
|
||||
obj.filters.push(
|
||||
new f.Noise({
|
||||
@@ -997,8 +1016,8 @@ $(document).ready(function () {
|
||||
onmove: function (x) {
|
||||
if (canvas.getActiveObject()) {
|
||||
var obj = canvas.getActiveObject();
|
||||
if (obj.filters.find((x) => x.type == 'Blur')) {
|
||||
obj.filters.find((x) => x.type == 'Blur').blur = x / 100;
|
||||
if (obj.filters.find((i) => i.type == 'Blur')) {
|
||||
obj.filters.find((i) => i.type == 'Blur').blur = x / 100;
|
||||
} else {
|
||||
obj.filters.push(
|
||||
new f.Blur({
|
||||
@@ -1006,9 +1025,9 @@ $(document).ready(function () {
|
||||
})
|
||||
);
|
||||
}
|
||||
obj.applyFilters();
|
||||
canvas.renderAll();
|
||||
}
|
||||
obj.applyFilters();
|
||||
canvas.renderAll();
|
||||
},
|
||||
onfinish: function (x) {
|
||||
save();
|
||||
|
||||
+762
-1196
File diff suppressed because it is too large
Load Diff
+70
-40
@@ -3,7 +3,6 @@ var GOOGLE_FONTS_API_KEY = 'GOOGLE_FONTS_API_KEY';
|
||||
|
||||
// for legacy browsers
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
const audioContext = new AudioContext();
|
||||
var oldsrc, oldobj;
|
||||
var oldtimelinepos;
|
||||
var speed = 1;
|
||||
@@ -58,7 +57,6 @@ var editingpanel = false;
|
||||
var files = [];
|
||||
var re = /(?:\.([^.]+))?$/;
|
||||
var filelist = [];
|
||||
var timeout;
|
||||
var spacehold = false;
|
||||
var spacerelease = false;
|
||||
var tempselection;
|
||||
@@ -94,7 +92,7 @@ var chromaslider, noiseslider, blurslider;
|
||||
var isChrome =
|
||||
window.chrome && Object.values(window.chrome).length !== 0;
|
||||
var eyeDropper;
|
||||
if (isChrome) {
|
||||
if (isChrome && typeof EyeDropper !== 'undefined') {
|
||||
eyeDropper = new EyeDropper();
|
||||
}
|
||||
var presets = [
|
||||
@@ -156,20 +154,30 @@ var sliders = [];
|
||||
var hovertime = 0;
|
||||
var animatedtext = [];
|
||||
|
||||
// Get list of fonts
|
||||
$.ajax({
|
||||
url:
|
||||
'https://www.googleapis.com/webfonts/v1/webfonts?key=' +
|
||||
GOOGLE_FONTS_API_KEY +
|
||||
'&sort=alpha',
|
||||
type: 'GET',
|
||||
dataType: 'json', // added data type
|
||||
success: function (response) {
|
||||
response.items.forEach(function (item) {
|
||||
fonts.push(item.family);
|
||||
});
|
||||
},
|
||||
});
|
||||
// Get list of fonts.
|
||||
// Both API keys are placeholders in the repository - replace them to enable
|
||||
// the Google Fonts list and the Pixabay browser.
|
||||
const HAS_FONTS_KEY =
|
||||
GOOGLE_FONTS_API_KEY && GOOGLE_FONTS_API_KEY != 'GOOGLE_FONTS_API_KEY';
|
||||
const HAS_PIXABAY_KEY = API_KEY && API_KEY != 'PIXABAY_API';
|
||||
if (HAS_FONTS_KEY) {
|
||||
$.ajax({
|
||||
url:
|
||||
'https://www.googleapis.com/webfonts/v1/webfonts?key=' +
|
||||
GOOGLE_FONTS_API_KEY +
|
||||
'&sort=alpha',
|
||||
type: 'GET',
|
||||
dataType: 'json', // added data type
|
||||
success: function (response) {
|
||||
response.items.forEach(function (item) {
|
||||
fonts.push(item.family);
|
||||
});
|
||||
},
|
||||
error: function () {
|
||||
console.warn('Could not load the Google Fonts list');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Panel variants
|
||||
const canvas_panel =
|
||||
@@ -444,6 +452,25 @@ var text_items = {
|
||||
],
|
||||
};
|
||||
|
||||
// Without a Google Fonts key the font pickers would be empty, so fall back
|
||||
// to the families that are already bundled in the text browser.
|
||||
// (Declared here because it reads text_items, defined just above.)
|
||||
if (!HAS_FONTS_KEY) {
|
||||
Object.keys(text_items).forEach(function (group) {
|
||||
text_items[group].forEach(function (item) {
|
||||
if (fonts.indexOf(item.fontname) == -1) {
|
||||
fonts.push(item.fontname);
|
||||
}
|
||||
});
|
||||
});
|
||||
['Inter', 'Syne'].forEach(function (name) {
|
||||
if (fonts.indexOf(name) == -1) {
|
||||
fonts.push(name);
|
||||
}
|
||||
});
|
||||
fonts.sort();
|
||||
}
|
||||
|
||||
WebFont.load({
|
||||
google: {
|
||||
families: ['Syne'],
|
||||
@@ -460,7 +487,11 @@ try {
|
||||
var canvas2dBackend = new fabric.Canvas2dFilterBackend();
|
||||
|
||||
fabric.filterBackend = fabric.initFilterBackend();
|
||||
fabric.filterBackend = webglBackend;
|
||||
// Only take over the backend if WebGL actually initialized, otherwise filters
|
||||
// would silently break on machines without a usable WebGL context.
|
||||
if (webglBackend) {
|
||||
fabric.filterBackend = webglBackend;
|
||||
}
|
||||
|
||||
// Lottie support
|
||||
fabric.Lottie = fabric.util.createClass(fabric.Image, {
|
||||
@@ -491,14 +522,18 @@ fabric.Lottie = fabric.util.createClass(fabric.Image, {
|
||||
});
|
||||
|
||||
this.lottieItem.addEventListener('enterFrame', (e) => {
|
||||
this.canvas.requestRenderAll();
|
||||
if (this.canvas) {
|
||||
this.canvas.requestRenderAll();
|
||||
}
|
||||
});
|
||||
|
||||
this.lottieItem.addEventListener('DOMLoaded', () => {
|
||||
this.lottieItem.goToAndStop(currenttime, false);
|
||||
this.lottieItem.duration =
|
||||
this.lottieItem.getDuration(false) * 1000;
|
||||
this.canvas.requestRenderAll();
|
||||
if (this.canvas) {
|
||||
this.canvas.requestRenderAll();
|
||||
}
|
||||
canvas.renderAll();
|
||||
canvas.fire('lottie:loaded', { any: 'payload' });
|
||||
});
|
||||
@@ -508,7 +543,9 @@ fabric.Lottie = fabric.util.createClass(fabric.Image, {
|
||||
|
||||
goToSeconds: function (seconds) {
|
||||
this.lottieItem.goToAndStop(seconds, false);
|
||||
this.canvas.requestRenderAll();
|
||||
if (this.canvas) {
|
||||
this.canvas.requestRenderAll();
|
||||
}
|
||||
},
|
||||
goToFrame: function (frame) {
|
||||
this.lottieItem.goToAndStop(frame, true);
|
||||
@@ -758,30 +795,23 @@ textBoxControls.mr = new fabric.Control({
|
||||
|
||||
// Get any object by ID
|
||||
fabric.Canvas.prototype.getItemById = function (name) {
|
||||
var object = null,
|
||||
objects = this.getObjects();
|
||||
for (var i = 0, len = this.size(); i < len; i++) {
|
||||
if (objects[i].get('type') == 'group') {
|
||||
if (objects[i].get('id') && objects[i].get('id') === name) {
|
||||
object = objects[i];
|
||||
break;
|
||||
function search(list) {
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
const item = list[i];
|
||||
if (item.id && item.id === name) {
|
||||
return item;
|
||||
}
|
||||
var wip = i;
|
||||
for (var o = 0; o < objects[i]._objects.length; o++) {
|
||||
if (
|
||||
objects[wip]._objects[o].id &&
|
||||
objects[wip]._objects[o].id === name
|
||||
) {
|
||||
object = objects[wip]._objects[o];
|
||||
break;
|
||||
// Recurse so groups nested more than one level deep are found too
|
||||
if (item._objects && item._objects.length > 0) {
|
||||
const found = search(item._objects);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
} else if (objects[i].id && objects[i].id === name) {
|
||||
object = objects[i];
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return object;
|
||||
return search(this.getObjects());
|
||||
};
|
||||
|
||||
// Create the artboard
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ async function newLottieAnimation(x, y, json) {
|
||||
strokeWidth: 0,
|
||||
cursorDuration: 1,
|
||||
cursorDelay: 250,
|
||||
duration: duration * 1000,
|
||||
duration: duration,
|
||||
assetType: 'sprite',
|
||||
id: 'Sprite' + layer_count,
|
||||
objectCaching: false,
|
||||
|
||||
+211
-329
@@ -1,68 +1,108 @@
|
||||
const FPS = 30;
|
||||
let frame = 0;
|
||||
var chunks = [];
|
||||
var stream;
|
||||
var rec;
|
||||
var track;
|
||||
// Everything the current export needs to tear down when it finishes
|
||||
var exportAudio = null;
|
||||
|
||||
function timeout(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
// Route every sound source of the project into a single destination node.
|
||||
//
|
||||
// This has to be one shared AudioContext with one destination:
|
||||
// - MediaRecorder only records the first audio track of a stream, so
|
||||
// adding one track per source silently dropped all but one of them,
|
||||
// - a context per source was never closed, and browsers cap how many a
|
||||
// page may hold.
|
||||
function buildExportAudio(stream) {
|
||||
const ctx = new AudioContext();
|
||||
const destination = ctx.createMediaStreamDestination();
|
||||
const elements = [];
|
||||
var connected = false;
|
||||
|
||||
function initRecorder() {
|
||||
stream = document.getElementById('canvasrecord').captureStream(0);
|
||||
track = stream.getVideoTracks()[0];
|
||||
|
||||
if (!track.requestFrame) {
|
||||
track.requestFrame = () => stream.requestFrame();
|
||||
function connect(element) {
|
||||
try {
|
||||
ctx.createMediaElementSource(element).connect(destination);
|
||||
connected = true;
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Already bound to another context, or tainted by CORS
|
||||
console.warn('Could not route audio for export', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
rec = new MediaRecorder(stream, {
|
||||
bitsPerSecond: 3200000,
|
||||
objects.forEach(function (object) {
|
||||
const obj = canvasrecord.getItemById(object.id);
|
||||
const p_keyframe = p_keyframes.find((x) => x.id == object.id);
|
||||
if (!obj || !p_keyframe) {
|
||||
return;
|
||||
}
|
||||
if (obj.get('assetType') == 'video') {
|
||||
const element = $(obj.getElement())[0];
|
||||
if (element) {
|
||||
connect(element);
|
||||
}
|
||||
} else if (
|
||||
obj.get('assetType') == 'audio' &&
|
||||
obj.get('audioSrc')
|
||||
) {
|
||||
// Audio layers used to be left out of the export entirely
|
||||
const element = new Audio(obj.get('audioSrc'));
|
||||
element.crossOrigin = 'anonymous';
|
||||
element.volume = obj.get('volume');
|
||||
if (connect(element)) {
|
||||
elements.push({
|
||||
element: element,
|
||||
start: p_keyframe.start,
|
||||
end: p_keyframe.end,
|
||||
trimstart: p_keyframe.trimstart,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rec.ondataavailable = function (evt) {
|
||||
console.log('chunky');
|
||||
chunks.push(evt.data);
|
||||
};
|
||||
if (background_audio != false && connect(background_audio)) {
|
||||
elements.push({
|
||||
element: background_audio,
|
||||
start: 0,
|
||||
end: duration,
|
||||
trimstart: 0,
|
||||
});
|
||||
}
|
||||
|
||||
rec.start();
|
||||
|
||||
console.log('Recorder has been started');
|
||||
|
||||
rec.onstart = function () {
|
||||
rec.pause();
|
||||
console.log('start!');
|
||||
};
|
||||
if (connected) {
|
||||
stream.addTrack(destination.stream.getAudioTracks()[0]);
|
||||
}
|
||||
return { context: ctx, elements: elements, timers: [] };
|
||||
}
|
||||
|
||||
async function recordFrame() {
|
||||
console.log(frame);
|
||||
|
||||
waitForEvent(rec, 'pause');
|
||||
|
||||
//rec.onpause = async function(e) {
|
||||
|
||||
// wake up the recorder
|
||||
rec.resume();
|
||||
recordAnimate(false, (frame / FPS) * 1000);
|
||||
//animate(false, (frame/FPS)*1000)
|
||||
// force write the frame
|
||||
track.requestFrame();
|
||||
|
||||
// wait until our frame-time elapsed
|
||||
await timeout(1000 / FPS);
|
||||
|
||||
// sleep recorder
|
||||
rec.pause();
|
||||
//}
|
||||
// Start the scheduled audio layers relative to the start of the recording
|
||||
function startExportAudio(audio) {
|
||||
if (!audio) {
|
||||
return;
|
||||
}
|
||||
audio.elements.forEach(function (item) {
|
||||
item.element.currentTime = item.trimstart / 1000;
|
||||
audio.timers.push(
|
||||
window.setTimeout(function () {
|
||||
item.element.play();
|
||||
}, Math.max(0, item.start))
|
||||
);
|
||||
audio.timers.push(
|
||||
window.setTimeout(function () {
|
||||
item.element.pause();
|
||||
}, Math.max(0, item.end))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function exportRecording() {
|
||||
rec.stop();
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
await waitForEvent(rec, 'stop');
|
||||
return new Blob(chunks);
|
||||
function stopExportAudio(audio) {
|
||||
if (!audio) {
|
||||
return;
|
||||
}
|
||||
audio.timers.forEach(window.clearTimeout);
|
||||
audio.timers = [];
|
||||
audio.elements.forEach(function (item) {
|
||||
item.element.pause();
|
||||
});
|
||||
if (audio.context && audio.context.state != 'closed') {
|
||||
audio.context.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Record canvas
|
||||
@@ -85,280 +125,122 @@ async function record() {
|
||||
document.body.removeChild(link);
|
||||
recording = false;
|
||||
updateRecordCanvas();
|
||||
} else {
|
||||
if (!recording) {
|
||||
recording = true;
|
||||
paused = true;
|
||||
await recordAnimate(0);
|
||||
$('#download-real').html('Rendering...');
|
||||
$('#download-real').addClass('downloading');
|
||||
var fps = 60;
|
||||
var aCtx = new AudioContext();
|
||||
function audioTimerLoop(callback, frequency) {
|
||||
var freq = frequency / 1000;
|
||||
var silence = aCtx.createGain();
|
||||
silence.gain.value = 0;
|
||||
silence.connect(aCtx.destination);
|
||||
onOSCend();
|
||||
var stopped = false;
|
||||
function onOSCend() {
|
||||
osc = aCtx.createOscillator();
|
||||
osc.onended = onOSCend;
|
||||
osc.connect(silence);
|
||||
osc.start(0);
|
||||
osc.stop(aCtx.currentTime + freq);
|
||||
callback(aCtx.currentTime);
|
||||
if (stopped) {
|
||||
osc.onended = function () {
|
||||
return;
|
||||
};
|
||||
}
|
||||
}
|
||||
return function () {
|
||||
stopped = true;
|
||||
};
|
||||
}
|
||||
var stopAnim = audioTimerLoop(renderAnim, 1000 / fps);
|
||||
var stream = document
|
||||
.getElementById('canvasrecord')
|
||||
.captureStream(fps);
|
||||
objects.forEach(function (object) {
|
||||
if (
|
||||
canvasrecord.getItemById(object.id).get('assetType') &&
|
||||
canvasrecord.getItemById(object.id).get('assetType') ==
|
||||
'video'
|
||||
) {
|
||||
var audio = $(
|
||||
canvasrecord.getItemById(object.id).getElement()
|
||||
)[0];
|
||||
var audioContext = new AudioContext();
|
||||
var audioSource =
|
||||
audioContext.createMediaElementSource(audio);
|
||||
var audioDestination =
|
||||
audioContext.createMediaStreamDestination();
|
||||
audioSource.connect(audioDestination);
|
||||
stream.addTrack(
|
||||
audioDestination.stream.getAudioTracks()[0]
|
||||
);
|
||||
}
|
||||
});
|
||||
if (background_audio != false) {
|
||||
var audioContext = new AudioContext();
|
||||
var audioSource =
|
||||
audioContext.createMediaElementSource(background_audio);
|
||||
var audioDestination =
|
||||
audioContext.createMediaStreamDestination();
|
||||
audioSource.connect(audioDestination);
|
||||
stream.addTrack(audioDestination.stream.getAudioTracks()[0]);
|
||||
background_audio.currentTime = 0;
|
||||
background_audio.play();
|
||||
}
|
||||
let chunks = [];
|
||||
var recorder = new MediaRecorder(stream, {
|
||||
bitsPerSecond: 3200000,
|
||||
});
|
||||
recorder.ondataavailable = (e) => chunks.push(e.data);
|
||||
recorder.onstop = (e) => {
|
||||
stopAnim();
|
||||
downloadRecording(chunks);
|
||||
animate(false, 0);
|
||||
$('#seekbar').offset({
|
||||
left:
|
||||
offset_left +
|
||||
$('#inner-timeline').offset().left +
|
||||
currenttime / timelinetime,
|
||||
});
|
||||
canvas.renderAll();
|
||||
console.log('Finished rendering');
|
||||
};
|
||||
recorder.start();
|
||||
|
||||
setTimeout(function () {
|
||||
recorder.stop();
|
||||
}, duration);
|
||||
|
||||
async function renderAnim(time) {
|
||||
await recordAnimate(time * 1000);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (recording) {
|
||||
return;
|
||||
}
|
||||
|
||||
recording = true;
|
||||
paused = true;
|
||||
await recordAnimate(0);
|
||||
$('#download-real').html('Rendering...');
|
||||
$('#download-real').addClass('downloading');
|
||||
|
||||
// Preferred path: render every frame offline, with no clock attached, so
|
||||
// nothing is dropped and video layers land on the exact frame.
|
||||
if (frameAccurateSupported()) {
|
||||
const blob = await renderFrameAccurate();
|
||||
if (blob) {
|
||||
deliverRecording(blob);
|
||||
return;
|
||||
}
|
||||
console.warn('Falling back to the real-time encoder');
|
||||
$('#download-real').html('Rendering...');
|
||||
await updateRecordCanvas();
|
||||
await recordAnimate(0);
|
||||
}
|
||||
|
||||
const fps = getExportFramerate();
|
||||
const aCtx = new AudioContext();
|
||||
|
||||
// requestAnimationFrame is throttled in background tabs, an oscillator is
|
||||
// not, so the render keeps running while the tab is hidden.
|
||||
function audioTimerLoop(callback, frequency) {
|
||||
const freq = frequency / 1000;
|
||||
const silence = aCtx.createGain();
|
||||
silence.gain.value = 0;
|
||||
silence.connect(aCtx.destination);
|
||||
var stopped = false;
|
||||
var osc;
|
||||
function onOSCend() {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
osc = aCtx.createOscillator();
|
||||
osc.onended = onOSCend;
|
||||
osc.connect(silence);
|
||||
osc.start(0);
|
||||
osc.stop(aCtx.currentTime + freq);
|
||||
callback(aCtx.currentTime);
|
||||
}
|
||||
onOSCend();
|
||||
return function () {
|
||||
stopped = true;
|
||||
if (osc) {
|
||||
osc.onended = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const stream = document
|
||||
.getElementById('canvasrecord')
|
||||
.captureStream(fps);
|
||||
exportAudio = buildExportAudio(stream);
|
||||
|
||||
const chunks = [];
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
bitsPerSecond: 3200000,
|
||||
});
|
||||
recorder.ondataavailable = (e) => chunks.push(e.data);
|
||||
recorder.onerror = (e) => {
|
||||
console.error('Recording failed', e);
|
||||
stopAnim();
|
||||
stopExportAudio(exportAudio);
|
||||
exportAudio = null;
|
||||
aCtx.close();
|
||||
resetRecordingUI();
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
stopAnim();
|
||||
stopExportAudio(exportAudio);
|
||||
exportAudio = null;
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
aCtx.close();
|
||||
downloadRecording(chunks);
|
||||
console.log('Finished rendering');
|
||||
};
|
||||
|
||||
// The capture is real time, so the animation clock is driven off the audio
|
||||
// clock and the recording is stopped once it has covered the timeline.
|
||||
var origin = null;
|
||||
var stopping = false;
|
||||
async function renderAnim(time) {
|
||||
if (origin === null) {
|
||||
origin = time;
|
||||
}
|
||||
const elapsed = (time - origin) * 1000;
|
||||
if (elapsed >= duration) {
|
||||
if (!stopping) {
|
||||
stopping = true;
|
||||
await recordAnimate(duration);
|
||||
if (recorder.state != 'inactive') {
|
||||
recorder.stop();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
await recordAnimate(elapsed);
|
||||
}
|
||||
|
||||
recorder.start();
|
||||
startExportAudio(exportAudio);
|
||||
var stopAnim = audioTimerLoop(renderAnim, 1000 / fps);
|
||||
|
||||
// Safety net: never leave the UI stuck if the oscillator clock stalls
|
||||
window.setTimeout(function () {
|
||||
if (recorder.state != 'inactive') {
|
||||
recorder.stop();
|
||||
}
|
||||
}, duration + 5000);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
initRecorder();
|
||||
|
||||
//await timeout(2000)
|
||||
|
||||
// draw one frame at a time
|
||||
while (frame++ < FPS * (duration/1000)) {
|
||||
await longDraw(); // do the long drawing
|
||||
await recordFrame(); // record at constant FPS
|
||||
}
|
||||
// now all the frames have been drawn
|
||||
const recorded = await exportRecording(); // we can get our final video file
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = URL.createObjectURL(recorded);
|
||||
a.download = "test.webm";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
recording = false;
|
||||
currenttime = 0;
|
||||
animate(false, 0);
|
||||
$("#seekbar").offset({left:offset_left+$("#inner-timeline").offset().left+(currenttime/timelinetime)});
|
||||
canvas.renderAll();
|
||||
resizeCanvas();
|
||||
if (background_audio != false) {
|
||||
background_audio.pause();
|
||||
background_audio = new Audio(background_audio.src)
|
||||
}
|
||||
$("#download-real").html("Download");
|
||||
$("#download-real").removeClass("downloading");
|
||||
updateRecordCanvas();
|
||||
|
||||
// Fake long drawing operations that make real-time recording impossible
|
||||
function longDraw() {
|
||||
recordAnimate((frame/FPS)*1000)
|
||||
return wait(Math.random() * 300)
|
||||
.then(recordAnimate((frame/FPS)*1000));
|
||||
}*/
|
||||
|
||||
/*
|
||||
paused = true;
|
||||
recording = true;
|
||||
$("#download-real").html("Rendering...");
|
||||
$("#download-real").addClass("downloading");
|
||||
var fps = 60;
|
||||
var aCtx = new AudioContext();
|
||||
function audioTimerLoop(callback, frequency) {
|
||||
var freq = frequency / 1000;
|
||||
var silence = aCtx.createGain();
|
||||
silence.gain.value = 0;
|
||||
silence.connect(aCtx.destination);
|
||||
onOSCend();
|
||||
var stopped = false;
|
||||
function onOSCend() {
|
||||
osc = aCtx.createOscillator();
|
||||
osc.onended = onOSCend;
|
||||
osc.connect(silence);
|
||||
osc.start(0);
|
||||
osc.stop(aCtx.currentTime + freq);
|
||||
callback(aCtx.currentTime);
|
||||
if (stopped) {
|
||||
osc.onended = function() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
};
|
||||
return function() {
|
||||
stopped = true;
|
||||
};
|
||||
}
|
||||
var stopAnim = audioTimerLoop(renderAnim, 1000/(fps));
|
||||
var stream = document.getElementById("canvasrecord").captureStream(fps);
|
||||
objects.forEach(function(object){
|
||||
if (canvasrecord.getItemById(object.id).get("assetType") && canvasrecord.getItemById(object.id).get("assetType") == "video") {
|
||||
var audio = $(canvasrecord.getItemById(object.id).getElement())[0];
|
||||
var audioContext = new AudioContext();
|
||||
var audioSource = audioContext.createMediaElementSource(audio);
|
||||
var audioDestination = audioContext.createMediaStreamDestination();
|
||||
audioSource.connect(audioDestination);
|
||||
stream.addTrack(audioDestination.stream.getAudioTracks()[0]);
|
||||
}
|
||||
})
|
||||
if (background_audio != false) {
|
||||
var audioContext = new AudioContext();
|
||||
var audioSource = audioContext.createMediaElementSource(background_audio);
|
||||
var audioDestination = audioContext.createMediaStreamDestination();
|
||||
audioSource.connect(audioDestination);
|
||||
stream.addTrack(audioDestination.stream.getAudioTracks()[0]);
|
||||
background_audio.currentTime = 0;
|
||||
background_audio.play();
|
||||
}
|
||||
let chunks = [];
|
||||
var recorder = new MediaRecorder(stream, {
|
||||
bitsPerSecond : 3200000,
|
||||
});
|
||||
recorder.ondataavailable = e => chunks.push(e.data);
|
||||
recorder.onstop = e => {
|
||||
stopAnim();
|
||||
downloadRecording(chunks);
|
||||
animate(false, 0);
|
||||
$("#seekbar").offset({left:offset_left+$("#inner-timeline").offset().left+(currenttime/timelinetime)});
|
||||
canvas.renderAll();
|
||||
console.log("Finished rendering")
|
||||
}
|
||||
recorder.start();
|
||||
|
||||
setTimeout(function() {
|
||||
recorder.stop();
|
||||
}, duration)
|
||||
|
||||
async function renderAnim(time) {
|
||||
await animate(false, time*1000);
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
/*
|
||||
$("#download-real").html("Rendering...");
|
||||
$("#download-real").addClass("downloading");
|
||||
|
||||
// browser check
|
||||
if (typeof MediaStreamTrackGenerator === undefined || typeof MediaStream === undefined || typeof VideoFrame === undefined) {
|
||||
console.log('Your browser does not support the web APIs used in this demo');
|
||||
return;
|
||||
}
|
||||
|
||||
// recording setup
|
||||
const fps = 60;
|
||||
const generator = new MediaStreamTrackGenerator({ kind: "video" });
|
||||
const writer = generator.writable.getWriter();
|
||||
const stream = new MediaStream();
|
||||
stream.addTrack(generator);
|
||||
const recorder = new MediaRecorder(stream, { mimeType: "video/webm" });
|
||||
recorder.start();
|
||||
|
||||
function timeout(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// animate stuff
|
||||
console.log('rendering...')
|
||||
console.log(duration);
|
||||
for (let i = 0; i < (duration/1000)*fps; i++) {
|
||||
animate(false, (i/fps)*1000);
|
||||
const frame = new VideoFrame(document.getElementById("canvasrecord"), {
|
||||
timestamp: (i / fps)*1000
|
||||
});
|
||||
await writer.write(frame);
|
||||
await timeout(100)
|
||||
console.log("frame "+(i/fps)*1000);
|
||||
}
|
||||
console.log('rendering done');
|
||||
|
||||
// stop recording and
|
||||
recorder.addEventListener("dataavailable", (evt) => {
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = URL.createObjectURL(evt.data);
|
||||
a.download = "test.webm";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
recording = false;
|
||||
currenttime = 0;
|
||||
animate(false, 0);
|
||||
$("#seekbar").offset({left:offset_left+$("#inner-timeline").offset().left+(currenttime/timelinetime)});
|
||||
canvas.renderAll();
|
||||
resizeCanvas();
|
||||
if (background_audio != false) {
|
||||
background_audio.pause();
|
||||
background_audio = new Audio(background_audio.src)
|
||||
}
|
||||
$("#download-real").html("Download");
|
||||
$("#download-real").removeClass("downloading");
|
||||
updateRecordCanvas();
|
||||
});
|
||||
recorder.stop();
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
// Frame-accurate offline renderer.
|
||||
//
|
||||
// The original export path captured the canvas with MediaRecorder in real
|
||||
// time, so anything the browser could not draw at 60fps was simply dropped and
|
||||
// video layers were sampled wherever they happened to be. This renders one
|
||||
// frame at a time with no clock attached: every frame is drawn at the frame
|
||||
// rate picked in the download modal, every video layer is seeked to the exact
|
||||
// frame time, and the audio is mixed offline.
|
||||
//
|
||||
// Needs WebCodecs (Chrome/Edge). record() falls back to the real-time path
|
||||
// when this is unavailable or fails.
|
||||
|
||||
// The muxer can only close a cluster on a video keyframe, and a cluster may
|
||||
// not span more than ~32s (block timecodes are a signed 16 bit offset), so
|
||||
// keyframes have to stay frequent.
|
||||
const RENDER_KEYFRAME_INTERVAL = 2; // seconds
|
||||
// A seek that never completes must not hang the whole export
|
||||
const RENDER_MAX_SEEK_WAIT = 2000;
|
||||
|
||||
// recordAnimate() drives video playback in real time; while rendering offline
|
||||
// the frames are seeked explicitly instead.
|
||||
var offlinerender = false;
|
||||
|
||||
function frameAccurateSupported() {
|
||||
return (
|
||||
typeof VideoEncoder !== 'undefined' &&
|
||||
typeof VideoFrame !== 'undefined' &&
|
||||
typeof WebMWriter !== 'undefined' &&
|
||||
typeof OfflineAudioContext !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
function renderProgress(text) {
|
||||
$('#download-real').html(text);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Media positioning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Put every time-based layer at exactly `time` and wait until it is actually
|
||||
// showing that frame. A plain currentTime assignment is asynchronous - drawing
|
||||
// before 'seeked' captures the previous frame.
|
||||
async function seekMediaForFrame(time) {
|
||||
const waits = [];
|
||||
objects.forEach(function (object) {
|
||||
const obj = canvasrecord.getItemById(object.id);
|
||||
const p_keyframe = p_keyframes.find((x) => x.id == object.id);
|
||||
if (!obj || !p_keyframe) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (obj.type == 'lottie') {
|
||||
obj.goToSeconds(time);
|
||||
return;
|
||||
}
|
||||
|
||||
if (obj.get('assetType') != 'video') {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = $(obj.getElement())[0];
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.pause();
|
||||
|
||||
const visible =
|
||||
time >= p_keyframe.trimstart + p_keyframe.start &&
|
||||
time <= p_keyframe.end;
|
||||
obj.set('visible', visible);
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
var target =
|
||||
(time - p_keyframe.start + p_keyframe.trimstart) / 1000;
|
||||
if (element.duration) {
|
||||
target = Math.min(target, Math.max(0, element.duration - 0.001));
|
||||
}
|
||||
target = Math.max(0, target);
|
||||
if (Math.abs(element.currentTime - target) < 0.0005) {
|
||||
return;
|
||||
}
|
||||
|
||||
waits.push(
|
||||
new Promise(function (resolve) {
|
||||
var settled = false;
|
||||
function finish() {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
element.removeEventListener('seeked', finish);
|
||||
element.removeEventListener('error', finish);
|
||||
resolve();
|
||||
}
|
||||
element.addEventListener('seeked', finish);
|
||||
element.addEventListener('error', finish);
|
||||
// A stuck seek must not hang the whole export
|
||||
window.setTimeout(finish, RENDER_MAX_SEEK_WAIT);
|
||||
try {
|
||||
element.currentTime = target;
|
||||
} catch (e) {
|
||||
finish();
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
await Promise.all(waits);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Every sound in the project, with where it sits on the timeline
|
||||
function collectAudioSources() {
|
||||
const sources = [];
|
||||
objects.forEach(function (object) {
|
||||
const obj = canvasrecord.getItemById(object.id);
|
||||
const p_keyframe = p_keyframes.find((x) => x.id == object.id);
|
||||
if (!obj || !p_keyframe) {
|
||||
return;
|
||||
}
|
||||
if (obj.get('assetType') == 'audio' && obj.get('audioSrc')) {
|
||||
sources.push({
|
||||
src: obj.get('audioSrc'),
|
||||
start: p_keyframe.start,
|
||||
end: p_keyframe.end,
|
||||
trimstart: p_keyframe.trimstart,
|
||||
volume: obj.get('volume'),
|
||||
});
|
||||
} else if (obj.get('assetType') == 'video' && obj.get('source')) {
|
||||
sources.push({
|
||||
src: obj.get('source'),
|
||||
start: p_keyframe.start,
|
||||
end: p_keyframe.end,
|
||||
trimstart: p_keyframe.trimstart,
|
||||
volume: 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (background_audio != false && background_audio.src) {
|
||||
sources.push({
|
||||
src: background_audio.src,
|
||||
start: 0,
|
||||
end: duration,
|
||||
trimstart: 0,
|
||||
volume: 1,
|
||||
});
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
// Mix the whole timeline in one pass. OfflineAudioContext renders faster than
|
||||
// real time and is sample-accurate, unlike routing live elements into a
|
||||
// MediaStream.
|
||||
async function renderAudioBuffer() {
|
||||
const sources = collectAudioSources();
|
||||
if (sources.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sampleRate = 48000;
|
||||
const frames = Math.ceil((duration / 1000) * sampleRate);
|
||||
if (!(frames > 0)) {
|
||||
return null;
|
||||
}
|
||||
const offline = new OfflineAudioContext(2, frames, sampleRate);
|
||||
|
||||
const decoded = [];
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const response = await fetch(source.src);
|
||||
const bytes = await response.arrayBuffer();
|
||||
const buffer = await offline.decodeAudioData(bytes);
|
||||
decoded.push({ source: source, buffer: buffer });
|
||||
} catch (e) {
|
||||
// Silent video, unsupported container, or an unreachable asset
|
||||
console.warn('Skipping audio source', e);
|
||||
}
|
||||
}
|
||||
if (decoded.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
decoded.forEach(function (item) {
|
||||
const node = offline.createBufferSource();
|
||||
node.buffer = item.buffer;
|
||||
const gain = offline.createGain();
|
||||
gain.gain.value =
|
||||
typeof item.source.volume == 'number' ? item.source.volume : 1;
|
||||
node.connect(gain);
|
||||
gain.connect(offline.destination);
|
||||
|
||||
const when = Math.max(0, item.source.start / 1000);
|
||||
const offset = Math.max(0, item.source.trimstart / 1000);
|
||||
const length = Math.max(
|
||||
0,
|
||||
(item.source.end - item.source.start) / 1000
|
||||
);
|
||||
if (length > 0 && offset < item.buffer.duration) {
|
||||
node.start(when, offset, length);
|
||||
}
|
||||
});
|
||||
|
||||
return await offline.startRendering();
|
||||
}
|
||||
|
||||
// Matroska needs the Opus identification header as CodecPrivate
|
||||
function buildOpusHead(channels, sampleRate) {
|
||||
const head = new Uint8Array(19);
|
||||
const view = new DataView(head.buffer);
|
||||
head.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0); // "OpusHead"
|
||||
head[8] = 1; // version
|
||||
head[9] = channels;
|
||||
view.setUint16(10, 3840, true); // pre-skip
|
||||
view.setUint32(12, sampleRate, true);
|
||||
view.setUint16(16, 0, true); // output gain
|
||||
head[18] = 0; // channel mapping family
|
||||
return head;
|
||||
}
|
||||
|
||||
async function encodeAudioBuffer(audioBuffer) {
|
||||
const channels = Math.min(2, audioBuffer.numberOfChannels);
|
||||
const sampleRate = audioBuffer.sampleRate;
|
||||
const config = {
|
||||
codec: 'opus',
|
||||
sampleRate: sampleRate,
|
||||
numberOfChannels: channels,
|
||||
bitrate: 128000,
|
||||
};
|
||||
if (typeof AudioEncoder === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const support = await AudioEncoder.isConfigSupported(config);
|
||||
if (!support || !support.supported) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
var failed = false;
|
||||
const encoder = new AudioEncoder({
|
||||
output: function (chunk) {
|
||||
chunks.push(chunk);
|
||||
},
|
||||
error: function (e) {
|
||||
failed = true;
|
||||
console.error('Audio encoding failed', e);
|
||||
},
|
||||
});
|
||||
encoder.configure(config);
|
||||
|
||||
const sliceFrames = Math.round(sampleRate / 10); // 100ms
|
||||
const planes = [];
|
||||
for (var c = 0; c < channels; c++) {
|
||||
planes.push(audioBuffer.getChannelData(c));
|
||||
}
|
||||
|
||||
for (
|
||||
var offset = 0;
|
||||
offset < audioBuffer.length && !failed;
|
||||
offset += sliceFrames
|
||||
) {
|
||||
const count = Math.min(sliceFrames, audioBuffer.length - offset);
|
||||
const planar = new Float32Array(count * channels);
|
||||
for (var ch = 0; ch < channels; ch++) {
|
||||
planar.set(
|
||||
planes[ch].subarray(offset, offset + count),
|
||||
ch * count
|
||||
);
|
||||
}
|
||||
const data = new AudioData({
|
||||
format: 'f32-planar',
|
||||
sampleRate: sampleRate,
|
||||
numberOfFrames: count,
|
||||
numberOfChannels: channels,
|
||||
timestamp: Math.round((offset / sampleRate) * 1e6),
|
||||
data: planar,
|
||||
});
|
||||
encoder.encode(data);
|
||||
data.close();
|
||||
}
|
||||
|
||||
await encoder.flush();
|
||||
encoder.close();
|
||||
if (failed || chunks.length == 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
chunks: chunks,
|
||||
sampleRate: sampleRate,
|
||||
channels: channels,
|
||||
codecPrivate: buildOpusHead(channels, sampleRate),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function pickVideoCodec(width, height, fps) {
|
||||
const candidates = [
|
||||
{ codec: 'vp09.00.10.08', name: 'VP9' },
|
||||
{ codec: 'vp8', name: 'VP8' },
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const config = {
|
||||
codec: candidate.codec,
|
||||
width: width,
|
||||
height: height,
|
||||
bitrate: 8000000,
|
||||
framerate: fps,
|
||||
};
|
||||
try {
|
||||
const support = await VideoEncoder.isConfigSupported(config);
|
||||
if (support && support.supported) {
|
||||
return { config: config, name: candidate.name };
|
||||
}
|
||||
} catch (e) {
|
||||
// try the next one
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Confirm the muxed file is actually decodable before handing it to the user.
|
||||
// The muxer is hand-rolled, so a silent failure here would otherwise reach
|
||||
// the user as a file that will not play.
|
||||
function verifyRenderedBlob(blob) {
|
||||
return new Promise(function (resolve) {
|
||||
const element = document.createElement('video');
|
||||
const url = URL.createObjectURL(blob);
|
||||
var settled = false;
|
||||
function finish(ok) {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
URL.revokeObjectURL(url);
|
||||
element.removeAttribute('src');
|
||||
resolve(ok);
|
||||
}
|
||||
element.onloadedmetadata = function () {
|
||||
finish(element.videoWidth > 0 && element.videoHeight > 0);
|
||||
};
|
||||
element.onerror = function () {
|
||||
finish(false);
|
||||
};
|
||||
window.setTimeout(function () {
|
||||
finish(false);
|
||||
}, 5000);
|
||||
element.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
// Returns a WebM Blob, or null to tell the caller to use the real-time path
|
||||
async function renderFrameAccurate() {
|
||||
const canvasElement = document.getElementById('canvasrecord');
|
||||
const width = canvasElement.width;
|
||||
const height = canvasElement.height;
|
||||
if (!(width > 0 && height > 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fps = getExportFramerate();
|
||||
const selected = await pickVideoCodec(width, height, fps);
|
||||
if (!selected) {
|
||||
console.warn('No supported WebCodecs video configuration');
|
||||
return null;
|
||||
}
|
||||
|
||||
offlinerender = true;
|
||||
var encoder = null;
|
||||
try {
|
||||
renderProgress('Mixing audio...');
|
||||
var audio = null;
|
||||
try {
|
||||
const audioBuffer = await renderAudioBuffer();
|
||||
if (audioBuffer) {
|
||||
audio = await encodeAudioBuffer(audioBuffer);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Rendering without audio', e);
|
||||
audio = null;
|
||||
}
|
||||
|
||||
const writer = new WebMWriter({
|
||||
codec: selected.name,
|
||||
width: width,
|
||||
height: height,
|
||||
audio: audio
|
||||
? {
|
||||
sampleRate: audio.sampleRate,
|
||||
channels: audio.channels,
|
||||
codecPrivate: audio.codecPrivate,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
const videoChunks = [];
|
||||
var encoderFailed = false;
|
||||
encoder = new VideoEncoder({
|
||||
output: function (chunk) {
|
||||
videoChunks.push(chunk);
|
||||
},
|
||||
error: function (e) {
|
||||
encoderFailed = true;
|
||||
console.error('Video encoding failed', e);
|
||||
},
|
||||
});
|
||||
encoder.configure(selected.config);
|
||||
|
||||
const totalFrames = Math.max(
|
||||
1,
|
||||
Math.round((duration / 1000) * fps)
|
||||
);
|
||||
const frameDuration = 1e6 / fps;
|
||||
const keyframeEvery = Math.max(
|
||||
1,
|
||||
Math.round(fps * RENDER_KEYFRAME_INTERVAL)
|
||||
);
|
||||
|
||||
for (var i = 0; i < totalFrames; i++) {
|
||||
if (encoderFailed) {
|
||||
break;
|
||||
}
|
||||
const time = (i / fps) * 1000;
|
||||
|
||||
await seekMediaForFrame(time);
|
||||
await recordAnimate(time);
|
||||
canvasrecord.renderAll();
|
||||
|
||||
const frame = new VideoFrame(canvasElement, {
|
||||
timestamp: Math.round(i * frameDuration),
|
||||
duration: Math.round(frameDuration),
|
||||
});
|
||||
encoder.encode(frame, { keyFrame: i % keyframeEvery == 0 });
|
||||
frame.close();
|
||||
|
||||
// Keep the encoder queue short so memory stays bounded
|
||||
while (encoder.encodeQueueSize > 8 && !encoderFailed) {
|
||||
await new Promise(function (resolve) {
|
||||
window.setTimeout(resolve, 4);
|
||||
});
|
||||
}
|
||||
|
||||
if (i % 5 == 0) {
|
||||
renderProgress(
|
||||
'Rendering ' +
|
||||
Math.round(((i + 1) / totalFrames) * 100) +
|
||||
'%'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await encoder.flush();
|
||||
encoder.close();
|
||||
encoder = null;
|
||||
if (encoderFailed || videoChunks.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
renderProgress('Muxing...');
|
||||
// Interleave both tracks in timestamp order: a cluster's blocks have to
|
||||
// be ordered, and this avoids threading the two producers together.
|
||||
const all = [];
|
||||
videoChunks.forEach(function (chunk) {
|
||||
all.push({ chunk: chunk, track: 1 });
|
||||
});
|
||||
if (audio) {
|
||||
audio.chunks.forEach(function (chunk) {
|
||||
all.push({ chunk: chunk, track: 2 });
|
||||
});
|
||||
}
|
||||
all.sort(function (a, b) {
|
||||
if (a.chunk.timestamp == b.chunk.timestamp) {
|
||||
return a.track - b.track;
|
||||
}
|
||||
return a.chunk.timestamp - b.chunk.timestamp;
|
||||
});
|
||||
all.forEach(function (item) {
|
||||
writer.addFrame(item.chunk, item.track);
|
||||
});
|
||||
|
||||
const blob = await writer.complete();
|
||||
if (!blob || blob.size == 0) {
|
||||
return null;
|
||||
}
|
||||
if (!(await verifyRenderedBlob(blob))) {
|
||||
console.warn(
|
||||
'Rendered file failed verification, using the real-time encoder'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return blob;
|
||||
} catch (e) {
|
||||
console.error('Frame-accurate render failed', e);
|
||||
return null;
|
||||
} finally {
|
||||
offlinerender = false;
|
||||
if (encoder && encoder.state != 'closed') {
|
||||
try {
|
||||
encoder.close();
|
||||
} catch (e) {
|
||||
// already torn down
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-18
@@ -1,10 +1,16 @@
|
||||
function animateText(group, ms, play, props, cv, id) {
|
||||
var starttime = p_keyframes.find((x) => x.id == id).start;
|
||||
ms -= starttime;
|
||||
const p_keyframe = p_keyframes.find((x) => x.id == id);
|
||||
if (!group || !p_keyframe) {
|
||||
return;
|
||||
}
|
||||
ms -= p_keyframe.start;
|
||||
var length = group._objects.length;
|
||||
var globaldelay = 0;
|
||||
// Every letter needs its own binding: the anime callbacks below run long
|
||||
// after the loop has finished, so `var` would make them all share the last
|
||||
// letter's state.
|
||||
for (var i = 0; i < length; i++) {
|
||||
var index = i;
|
||||
let index = i;
|
||||
if (props.order == 'backward') {
|
||||
index = length - i - 1;
|
||||
}
|
||||
@@ -12,9 +18,10 @@ function animateText(group, ms, play, props, cv, id) {
|
||||
let top = group.item(index).defaultTop;
|
||||
let scaleX = group.item(index).defaultScaleX;
|
||||
let scaleY = group.item(index).defaultScaleY;
|
||||
var delay = i * duration;
|
||||
var duration = props.duration / length;
|
||||
var animation = {
|
||||
// Named `step` so it does not shadow the global project duration
|
||||
let step = props.duration / length;
|
||||
let delay = i * step;
|
||||
let animation = {
|
||||
opacity: 0,
|
||||
top: top,
|
||||
left: left,
|
||||
@@ -22,7 +29,7 @@ function animateText(group, ms, play, props, cv, id) {
|
||||
scaleY: scaleY,
|
||||
};
|
||||
if (props.typeAnim == 'letter') {
|
||||
delay = i * duration - 100;
|
||||
delay = i * step - 100;
|
||||
} else if (props.typeAnim == 'word') {
|
||||
if (group.item(index).text == ' ') {
|
||||
globaldelay += 500;
|
||||
@@ -30,8 +37,8 @@ function animateText(group, ms, play, props, cv, id) {
|
||||
delay = globaldelay;
|
||||
}
|
||||
if (props.preset == 'typewriter') {
|
||||
delay = i * duration;
|
||||
duration = 20;
|
||||
delay = i * step;
|
||||
step = 20;
|
||||
} else if (props.preset == 'fade in') {
|
||||
} else if (props.preset == 'slide top') {
|
||||
animation.top += 20;
|
||||
@@ -48,14 +55,14 @@ function animateText(group, ms, play, props, cv, id) {
|
||||
animation.scaleX = 1.5;
|
||||
animation.scaleY = 1.5;
|
||||
}
|
||||
if (delay < 0) {
|
||||
if (!(delay > 0)) {
|
||||
delay = 0;
|
||||
}
|
||||
if (duration < 20) {
|
||||
duration = 20;
|
||||
if (!(step > 20)) {
|
||||
step = 20;
|
||||
}
|
||||
var start = false;
|
||||
var instance = anime({
|
||||
let start = false;
|
||||
let instance = anime({
|
||||
targets: animation,
|
||||
delay: delay,
|
||||
opacity: 1,
|
||||
@@ -63,7 +70,7 @@ function animateText(group, ms, play, props, cv, id) {
|
||||
top: top,
|
||||
scaleX: scaleX,
|
||||
scaleY: scaleY,
|
||||
duration: duration,
|
||||
duration: step,
|
||||
easing: props.easing,
|
||||
autoplay: play,
|
||||
update: function () {
|
||||
@@ -228,8 +235,7 @@ class AnimatedText {
|
||||
var obj = cv.getItemById(this.id);
|
||||
var left = obj.left;
|
||||
var top = obj.top;
|
||||
var scaleX = obj,
|
||||
scaleX;
|
||||
var scaleX = obj.scaleX;
|
||||
var scaleY = obj.scaleY;
|
||||
var angle = obj.angle;
|
||||
var start = p_keyframes.find((x) => x.id == this.id).start;
|
||||
@@ -260,10 +266,16 @@ class AnimatedText {
|
||||
cv,
|
||||
this.id
|
||||
);
|
||||
animate(currenttime, false);
|
||||
animate(false, currenttime);
|
||||
save();
|
||||
}
|
||||
assignTo(id, text, props) {
|
||||
this.id = id;
|
||||
if (text !== undefined) {
|
||||
this.text = text;
|
||||
}
|
||||
if (props !== undefined) {
|
||||
this.props = props;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+174
-91
@@ -1,5 +1,8 @@
|
||||
// Update panel (when selecting / de-selecting objects)
|
||||
function updatePanel(selection) {
|
||||
if (selection && !canvas.getActiveObject()) {
|
||||
selection = false;
|
||||
}
|
||||
if (!selection) {
|
||||
$('#align').addClass('align-off');
|
||||
$('#object-specific').html(canvas_panel);
|
||||
@@ -330,17 +333,19 @@ function convertToHex(nonHexColorString) {
|
||||
|
||||
function updateStrokeValues() {
|
||||
const object = canvas.getActiveObject();
|
||||
if (!object) {
|
||||
return;
|
||||
}
|
||||
$('.line-join-active').removeClass('line-join-active');
|
||||
if (
|
||||
object.get('strokeDashArray') == false &&
|
||||
object.get('strokeWidth') == 0
|
||||
) {
|
||||
const dash = object.get('strokeDashArray');
|
||||
const hasDash = Array.isArray(dash) && dash.length > 0;
|
||||
if (!hasDash && object.get('strokeWidth') == 0) {
|
||||
$('#miter').addClass('line-join-active');
|
||||
$('#miter img').attr('src', 'assets/miter-active.svg');
|
||||
} else if (object.get('strokeDashArray') == false) {
|
||||
} else if (!hasDash) {
|
||||
$('#bevel').addClass('line-join-active');
|
||||
$('#bevel img').attr('src', 'assets/bevel-active.svg');
|
||||
} else if (object.get('strokeDashArray') == [10, 5]) {
|
||||
} else if (dash[0] == 10 && dash[1] == 5) {
|
||||
$('#round').addClass('line-join-active');
|
||||
$('#round img').attr('src', 'assets/round-active.svg');
|
||||
} else {
|
||||
@@ -350,39 +355,39 @@ function updateStrokeValues() {
|
||||
}
|
||||
|
||||
function toggleAnimationOrder() {
|
||||
var object = canvas.getActiveObject();
|
||||
const text = activeAnimatedText();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
$('.order-toggle-item-active').removeClass(
|
||||
'order-toggle-item-active'
|
||||
);
|
||||
if ($(this).attr('id') == 'order-backward') {
|
||||
animatedtext
|
||||
.find((x) => x.id == object.id)
|
||||
.setProp({ order: 'backward' }, canvas);
|
||||
text.setProp({ order: 'backward' });
|
||||
} else if ($(this).attr('id') == 'order-forward') {
|
||||
animatedtext
|
||||
.find((x) => x.id == object.id)
|
||||
.setProp({ order: 'forward' }, canvas);
|
||||
text.setProp({ order: 'forward' });
|
||||
}
|
||||
$(this).addClass('order-toggle-item-active');
|
||||
animate(currenttime, false);
|
||||
// animate(play, time) - the arguments used to be the other way round, so
|
||||
// the canvas never refreshed after changing a text animation.
|
||||
animate(false, currenttime);
|
||||
save();
|
||||
}
|
||||
function toggleAnimationType() {
|
||||
var object = canvas.getActiveObject();
|
||||
const text = activeAnimatedText();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
$('.order-toggle-item-active-2').removeClass(
|
||||
'order-toggle-item-active-2'
|
||||
);
|
||||
if ($(this).attr('id') == 'type-words') {
|
||||
animatedtext
|
||||
.find((x) => x.id == object.id)
|
||||
.setProp({ typeAnim: 'word' }, canvas);
|
||||
text.setProp({ typeAnim: 'word' });
|
||||
} else if ($(this).attr('id') == 'type-letters') {
|
||||
animatedtext
|
||||
.find((x) => x.id == object.id)
|
||||
.setProp({ typeAnim: 'letter' }, canvas);
|
||||
text.setProp({ typeAnim: 'letter' });
|
||||
}
|
||||
$(this).addClass('order-toggle-item-active-2');
|
||||
animate(currenttime, false);
|
||||
animate(false, currenttime);
|
||||
save();
|
||||
}
|
||||
$(document).on(
|
||||
@@ -473,7 +478,7 @@ function updatePanelValues() {
|
||||
var tempstore = false;
|
||||
var object = canvas.getActiveObject();
|
||||
if (
|
||||
canvas.getActiveObjects.length > 1 ||
|
||||
canvas.getActiveObjects().length > 1 ||
|
||||
object.get('type') == 'activeSelection'
|
||||
) {
|
||||
object = object.toGroup();
|
||||
@@ -590,7 +595,8 @@ function updatePanelValues() {
|
||||
|
||||
// Update opacity input
|
||||
function updateInputs(id) {
|
||||
if (canvas.getActiveObject().get('assetType') == 'audio') {
|
||||
const active = canvas.getActiveObject();
|
||||
if (!active || active.get('assetType') == 'audio') {
|
||||
return false;
|
||||
}
|
||||
if ($('#object-o input').val() > 100) {
|
||||
@@ -604,7 +610,7 @@ function updateInputs(id) {
|
||||
id == 'object-color-fill-opacity'
|
||||
) {
|
||||
if ($('#object-color-fill-opacity input').val() > 100) {
|
||||
$('#object-color-fill-opacity').val(100);
|
||||
$('#object-color-fill-opacity input').val(100);
|
||||
} else if ($('#object-color-fill-opacity input').val() < 0) {
|
||||
$('#object-color-fill-opacity input').val(0);
|
||||
}
|
||||
@@ -628,7 +634,7 @@ function updateInputs(id) {
|
||||
id == 'object-color-stroke-opacity'
|
||||
) {
|
||||
if ($('#object-color-stroke-opacity input').val() > 100) {
|
||||
$('#object-color-stroke-opacity').val(100);
|
||||
$('#object-color-stroke-opacity input').val(100);
|
||||
} else if ($('#object-color-stroke-opacity input').val() < 0) {
|
||||
$('#object-color-stroke-opacity input').val(0);
|
||||
}
|
||||
@@ -652,7 +658,7 @@ function updateInputs(id) {
|
||||
id == 'object-color-shadow-opacity'
|
||||
) {
|
||||
if ($('#object-color-shadow-opacity input').val() > 100) {
|
||||
$('#object-color-shadow-opacity').val(100);
|
||||
$('#object-color-shadow-opacity input').val(100);
|
||||
} else if ($('#object-color-shadow-opacity input').val() < 0) {
|
||||
$('#object-color-shadow-opacity input').val(0);
|
||||
}
|
||||
@@ -673,7 +679,6 @@ function updateInputs(id) {
|
||||
|
||||
// Update object position based on panel input values
|
||||
function updateObjectValues(type) {
|
||||
autoSave();
|
||||
if (canvas.getActiveObjects().length > 0) {
|
||||
if ($(this).find('input').val() || type) {
|
||||
var object = canvas.getActiveObject();
|
||||
@@ -832,14 +837,11 @@ function updateObjectValues(type) {
|
||||
}
|
||||
}
|
||||
function setTextAnimation() {
|
||||
var object = canvas.getActiveObject();
|
||||
animatedtext
|
||||
.find((x) => x.id == object.id)
|
||||
.reset(
|
||||
$(this).parent().find('input').val(),
|
||||
animatedtext.find((x) => x.id == object.id).props,
|
||||
canvas
|
||||
);
|
||||
const text = activeAnimatedText();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
text.reset($(this).parent().find('input').val(), text.props, canvas);
|
||||
}
|
||||
|
||||
$(document).on('input', '.property-input', updateObjectValues);
|
||||
@@ -850,8 +852,10 @@ $(document).on('click', '#animatedset', setTextAnimation);
|
||||
// Toggle picker (maybe it could be condensed?)
|
||||
function togglePicker() {
|
||||
const object = canvas.getActiveObject();
|
||||
if (!object && $(this).attr('id') != 'canvas-color') {
|
||||
return;
|
||||
}
|
||||
if (!o_fill.isOpen()) {
|
||||
newcolorkeyframe = true;
|
||||
if ($(this).attr('id') == 'object-color-fill') {
|
||||
colormode = 'fill';
|
||||
o_fill.setColor(object.get('fill'));
|
||||
@@ -879,7 +883,6 @@ function togglePicker() {
|
||||
colormode = 'shadow';
|
||||
o_fill.setColor(object.shadow.color);
|
||||
}
|
||||
newcolorkeyframe = false;
|
||||
o_fill.show();
|
||||
} else {
|
||||
o_fill.hide();
|
||||
@@ -917,6 +920,7 @@ function populateGrid(type) {
|
||||
});
|
||||
} else if (type == 'image-tool') {
|
||||
$('#images-grid').html('');
|
||||
$('#categories').html('');
|
||||
image_categories.forEach(function (category) {
|
||||
$('#categories').append(
|
||||
"<div class='category' data-name='" +
|
||||
@@ -930,6 +934,7 @@ function populateGrid(type) {
|
||||
});
|
||||
} else if (type == 'video-tool') {
|
||||
$('#images-grid').html('');
|
||||
$('#categories').html('');
|
||||
video_categories.forEach(function (category) {
|
||||
$('#categories').append(
|
||||
"<div class='category' data-name='" +
|
||||
@@ -959,7 +964,7 @@ function populateGrid(type) {
|
||||
item.key +
|
||||
"'><img class='delete-media' draggable=false src='assets/more-options.svg'><img draggable=false onload='onLoadImage(this)' class='image-thing' src='" +
|
||||
item.thumb +
|
||||
"'</div>"
|
||||
"'></div>"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -999,6 +1004,7 @@ function populateGrid(type) {
|
||||
}
|
||||
} else if (type == 'audio-tool') {
|
||||
var flag = false;
|
||||
$('#audio-list').html('');
|
||||
audio_items.forEach(function (item) {
|
||||
if (item.src == background_key) {
|
||||
flag = true;
|
||||
@@ -1015,7 +1021,7 @@ function populateGrid(type) {
|
||||
item.desc +
|
||||
"</a><div class='audio-info-duration'>" +
|
||||
item.duration +
|
||||
'</div></div></div></div>'
|
||||
'</div></div></div>'
|
||||
);
|
||||
} else {
|
||||
$('#audio-list').append(
|
||||
@@ -1031,7 +1037,7 @@ function populateGrid(type) {
|
||||
item.desc +
|
||||
"</a><div class='audio-info-duration'>" +
|
||||
item.duration +
|
||||
'</div></div></div></div>'
|
||||
'</div></div></div>'
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1057,7 +1063,7 @@ function populateGrid(type) {
|
||||
},
|
||||
});
|
||||
$('#shapes-cont').append(
|
||||
"<div id='item-text' class='add-text noselect' data-font='" +
|
||||
"<div class='item-text add-text noselect' data-font='" +
|
||||
text.fontname +
|
||||
"' style='font-family: " +
|
||||
text.fontname +
|
||||
@@ -1074,7 +1080,7 @@ function populateGrid(type) {
|
||||
},
|
||||
});
|
||||
$('#shapes-cont').append(
|
||||
"<div id='item-text' class='add-text noselect' data-font='" +
|
||||
"<div class='item-text add-text noselect' data-font='" +
|
||||
text.fontname +
|
||||
"' style='font-family: " +
|
||||
text.fontname +
|
||||
@@ -1091,7 +1097,7 @@ function populateGrid(type) {
|
||||
},
|
||||
});
|
||||
$('#shapes-cont').append(
|
||||
"<div id='item-text' class='add-text noselect' data-font='" +
|
||||
"<div class='item-text add-text noselect' data-font='" +
|
||||
text.fontname +
|
||||
"' style='font-family: " +
|
||||
text.fontname +
|
||||
@@ -1108,7 +1114,7 @@ function populateGrid(type) {
|
||||
},
|
||||
});
|
||||
$('#shapes-cont').append(
|
||||
"<div id='item-text' class='add-text noselect' data-font='" +
|
||||
"<div class='item-text add-text noselect' data-font='" +
|
||||
text.fontname +
|
||||
"' style='font-family: " +
|
||||
text.fontname +
|
||||
@@ -1125,7 +1131,7 @@ function populateGrid(type) {
|
||||
},
|
||||
});
|
||||
$('#shapes-cont').append(
|
||||
"<div id='item-text' class='add-text noselect' data-font='" +
|
||||
"<div class='item-text add-text noselect' data-font='" +
|
||||
text.fontname +
|
||||
"' style='font-family: " +
|
||||
text.fontname +
|
||||
@@ -1175,27 +1181,27 @@ function updateBrowser(type) {
|
||||
if (type == 'image-tool') {
|
||||
$('#browser-container').html(image_browser);
|
||||
populateGrid(type);
|
||||
$('#browser').on('scroll', scrollBottom);
|
||||
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
|
||||
} else if (type == 'shape-tool') {
|
||||
$('#browser-container').html(shape_browser);
|
||||
populateGrid(type);
|
||||
$('#browser').on('scroll', scrollBottom);
|
||||
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
|
||||
} else if (type == 'video-tool') {
|
||||
$('#browser-container').html(video_browser);
|
||||
populateGrid(type);
|
||||
$('#browser').on('scroll', scrollBottom);
|
||||
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
|
||||
} else if (type == 'text-tool') {
|
||||
$('#browser-container').html(text_browser);
|
||||
populateGrid(type);
|
||||
$('#browser').on('scroll', scrollBottom);
|
||||
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
|
||||
} else if (type == 'upload-tool') {
|
||||
$('#browser-container').html(upload_browser);
|
||||
populateGrid('images-tab');
|
||||
$('#browser').on('scroll', scrollBottom);
|
||||
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
|
||||
} else if (type == 'audio-tool') {
|
||||
$('#browser-container').html(audio_browser);
|
||||
populateGrid(type);
|
||||
$('#browser').on('scroll', scrollBottom);
|
||||
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1260,10 +1266,14 @@ $(document).on('click', '.tool:not(.tool-active)', switchTool);
|
||||
|
||||
// Replace image or video by dragging on top and holding a key
|
||||
function replaceObject(src, object) {
|
||||
if (!src || !object) {
|
||||
return;
|
||||
}
|
||||
var img = new Image();
|
||||
var width = object.width;
|
||||
var height = object.height;
|
||||
oldsrc = object._originalElement.currentSrc;
|
||||
const element = object.getElement();
|
||||
oldsrc = element ? element.currentSrc || element.src : null;
|
||||
oldobj = object;
|
||||
img.onload = function () {
|
||||
object.setElement(img);
|
||||
@@ -1271,6 +1281,9 @@ function replaceObject(src, object) {
|
||||
object.set('height', height);
|
||||
canvas.renderAll();
|
||||
};
|
||||
img.onerror = function () {
|
||||
console.warn('Could not load replacement image');
|
||||
};
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
@@ -1339,7 +1352,9 @@ function dragObject(e) {
|
||||
(replacing && !e.ctrlKey)
|
||||
) {
|
||||
drag.css('visibility', 'visible');
|
||||
replaceObject(oldsrc, oldobj);
|
||||
if (oldsrc && oldobj) {
|
||||
replaceObject(oldsrc, oldobj);
|
||||
}
|
||||
replacing = false;
|
||||
canvas.discardActiveObject();
|
||||
$('#replace-image').removeClass('replace-active');
|
||||
@@ -1361,7 +1376,21 @@ function dragObject(e) {
|
||||
$('#properties').removeClass('noselect');
|
||||
$('#controls').removeClass('noselect');
|
||||
draggingPanel = false;
|
||||
$('body').off('mousemove', dragging).off('mouseup', released);
|
||||
// An aborted drag (pointercancel, a stray native dragstart, or the window
|
||||
// losing focus) carries no pointer position, so there is nowhere to drop.
|
||||
// Undo any in-progress replacement and bin the ghost.
|
||||
if (!e || e.type != 'pointerup') {
|
||||
if (replacing) {
|
||||
if (oldsrc && oldobj) {
|
||||
replaceObject(oldsrc, oldobj);
|
||||
}
|
||||
replacing = false;
|
||||
canvas.discardActiveObject();
|
||||
canvas.renderAll();
|
||||
}
|
||||
drag.remove();
|
||||
return false;
|
||||
}
|
||||
canvasx = canvas.getPointer(e).x;
|
||||
canvasy = canvas.getPointer(e).y;
|
||||
var xpos = canvasx + offsetx - artboard.get('left');
|
||||
@@ -1561,16 +1590,32 @@ function dragObject(e) {
|
||||
}
|
||||
drag.remove();
|
||||
}
|
||||
$('body').on('mouseup', released).on('mousemove', dragging);
|
||||
// No pointer capture here (unlike the timeline drags): the drop target is
|
||||
// worked out from the canvas hover state, and capturing would retarget those
|
||||
// events to the panel item and leave overCanvas permanently false.
|
||||
bindPointerDrag(e, null, dragging, released);
|
||||
}
|
||||
$(document).on('mousedown', '.image-grid-item', dragObject);
|
||||
$(document).on('mousedown', '.video-grid-item', dragObject);
|
||||
$(document).on('mousedown', '.grid-item', dragObject);
|
||||
$(document).on('mousedown', '.grid-emoji-item', dragObject);
|
||||
$(document).on('mousedown', '.add-text', dragObject);
|
||||
$(document).on('mousedown click mouseup', '.credit', function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
$(document).on('pointerdown', '.image-grid-item', dragObject);
|
||||
$(document).on('pointerdown', '.video-grid-item', dragObject);
|
||||
$(document).on('pointerdown', '.grid-item', dragObject);
|
||||
$(document).on('pointerdown', '.grid-emoji-item', dragObject);
|
||||
$(document).on('pointerdown', '.add-text', dragObject);
|
||||
$(document).on(
|
||||
'pointerdown mousedown click mouseup',
|
||||
'.credit',
|
||||
function (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
);
|
||||
// Panel items must never become native drag sources - a native drag swallows
|
||||
// the pointerup and leaves the dragged ghost stuck to the cursor
|
||||
$(document).on(
|
||||
'dragstart',
|
||||
'.image-grid-item, .video-grid-item, .grid-item, .grid-emoji-item, .add-text, .credit',
|
||||
function (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
);
|
||||
|
||||
// Collapse library
|
||||
function collapsePanel() {
|
||||
@@ -1613,18 +1658,29 @@ function setPreset() {
|
||||
}
|
||||
$(document).on('change', '#preset', setPreset);
|
||||
|
||||
function activeAnimatedText() {
|
||||
const object = canvas.getActiveObject();
|
||||
if (!object) {
|
||||
return null;
|
||||
}
|
||||
return animatedtext.find((x) => x.id == object.id) || null;
|
||||
}
|
||||
function setTextPreset() {
|
||||
var object = canvas.getActiveObject();
|
||||
animatedtext
|
||||
.find((x) => x.id == object.id)
|
||||
.setProp({ preset: $(this).val() }, canvas);
|
||||
const text = activeAnimatedText();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
text.setProp({ preset: $(this).val() });
|
||||
animate(false, currenttime);
|
||||
save();
|
||||
}
|
||||
function setTextEasing() {
|
||||
var object = canvas.getActiveObject();
|
||||
animatedtext
|
||||
.find((x) => x.id == object.id)
|
||||
.setProp({ easing: $(this).val() }, canvas);
|
||||
const text = activeAnimatedText();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
text.setProp({ easing: $(this).val() });
|
||||
animate(false, currenttime);
|
||||
save();
|
||||
}
|
||||
$(document).on('change', '#preset-picker', setTextPreset);
|
||||
@@ -1651,11 +1707,14 @@ function saveLayerName() {
|
||||
if ($('.name-active').val() == '') {
|
||||
$('.name-active').val('Untitled layer');
|
||||
}
|
||||
objects.find(
|
||||
const entry = objects.find(
|
||||
(x) =>
|
||||
x.id == $('.name-active').parent().parent().attr('data-object')
|
||||
).label = $('.name-active').val();
|
||||
save();
|
||||
);
|
||||
if (entry) {
|
||||
entry.label = $('.name-active').val();
|
||||
save();
|
||||
}
|
||||
$('.name-active').removeClass('name-active');
|
||||
if (window.getSelection) {
|
||||
if (window.getSelection().empty) {
|
||||
@@ -1750,6 +1809,9 @@ function fancyTimeFormat(duration) {
|
||||
|
||||
function loadMoreMedia() {
|
||||
var value = $('#browser-search input').val();
|
||||
if (!HAS_PIXABAY_KEY) {
|
||||
return;
|
||||
}
|
||||
if (value != '' && page != false) {
|
||||
page += 1;
|
||||
if ($('#image-tool').hasClass('tool-active')) {
|
||||
@@ -1772,7 +1834,7 @@ function loadMoreMedia() {
|
||||
hit.user +
|
||||
"</a><img draggable=false onload='onLoadImage(this)' src='" +
|
||||
hit.webformatURL +
|
||||
"'</div>"
|
||||
"'></div>"
|
||||
);
|
||||
});
|
||||
} else {
|
||||
@@ -1800,7 +1862,7 @@ function loadMoreMedia() {
|
||||
hit.user +
|
||||
"</a><div id='time-video'>" +
|
||||
fancyTimeFormat(hit.duration) +
|
||||
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'</div>"
|
||||
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'></div>"
|
||||
);
|
||||
createVideoThumbnail(video, 250, 0, true).then(function (
|
||||
data
|
||||
@@ -1821,6 +1883,15 @@ function loadMoreMedia() {
|
||||
function search() {
|
||||
page = 1;
|
||||
var value = $('#browser-search input').val();
|
||||
const pixabaySearch =
|
||||
$('#image-tool').hasClass('tool-active') ||
|
||||
$('#video-tool').hasClass('tool-active');
|
||||
if (pixabaySearch && !HAS_PIXABAY_KEY) {
|
||||
$('#shapes-cont').html(
|
||||
"<div id='no-results'>Image and video search needs a Pixabay API key. Set API_KEY in js/init.js.</div>"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if ($('#image-tool').hasClass('tool-active')) {
|
||||
var URL =
|
||||
'https://pixabay.com/api/?key=' +
|
||||
@@ -1845,7 +1916,7 @@ function search() {
|
||||
hit.user +
|
||||
"</a><img draggable=false onload='onLoadImage(this)' src='" +
|
||||
hit.webformatURL +
|
||||
"'</div>"
|
||||
"'></div>"
|
||||
);
|
||||
});
|
||||
} else {
|
||||
@@ -1883,7 +1954,7 @@ function search() {
|
||||
hit.user +
|
||||
"</a><div id='time-video'>" +
|
||||
fancyTimeFormat(hit.duration) +
|
||||
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'</div>"
|
||||
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'></div>"
|
||||
);
|
||||
//createVideoThumbnail(video, 250, 0, true).then(function(data){
|
||||
$(".image-grid-item[data-src='" + video + "']")
|
||||
@@ -1963,7 +2034,7 @@ function search() {
|
||||
},
|
||||
});
|
||||
$('#shapes-cont').append(
|
||||
"<div id='item-text' class='add-text noselect' data-font='" +
|
||||
"<div class='item-text add-text noselect' data-font='" +
|
||||
font +
|
||||
"' style='font-family: " +
|
||||
font +
|
||||
@@ -2069,10 +2140,7 @@ function checkFilter() {
|
||||
resetFilters();
|
||||
if (canvas.getActiveObject()) {
|
||||
var obj = canvas.getActiveObject();
|
||||
if (
|
||||
canvas.getActiveObjects().length == 1 &&
|
||||
(obj.type == 'image' || obj.type == 'video')
|
||||
) {
|
||||
if (canvas.getActiveObjects().length == 1 && obj.filters) {
|
||||
var value = 'none';
|
||||
if (obj.filters.length > 0) {
|
||||
obj.filters.forEach(function (filter) {
|
||||
@@ -2224,6 +2292,9 @@ $(document).on('click', '#reset-filters', removeFilters);
|
||||
function updateChromaValues() {
|
||||
if (canvas.getActiveObject()) {
|
||||
var obj = canvas.getActiveObject();
|
||||
if (!obj.filters) {
|
||||
return;
|
||||
}
|
||||
if ($('.status-active').attr('id') == 'status-on') {
|
||||
if (obj.filters.find((x) => x.type == 'RemoveColor')) {
|
||||
obj.filters.find((x) => x.type == 'RemoveColor').distance =
|
||||
@@ -2264,7 +2335,8 @@ function updateChromaUI() {
|
||||
$('.status-active').removeClass('status-active');
|
||||
$('#status-on').addClass('status-active');
|
||||
chromaslider.setValue(
|
||||
obj.filters.find((x) => x.type == 'RemoveColor').distance
|
||||
obj.filters.find((x) => x.type == 'RemoveColor').distance *
|
||||
100
|
||||
);
|
||||
$('#chroma-color input').val(
|
||||
obj.filters.find((x) => x.type == 'RemoveColor').color
|
||||
@@ -2278,7 +2350,7 @@ function updateChromaUI() {
|
||||
$('#status-off').addClass('status-active');
|
||||
chromaslider.setValue(1);
|
||||
$('#chroma-color input').val('#FFFFFF');
|
||||
$('#color-chroma-side').css('background-color', '#FFFFF');
|
||||
$('#color-chroma-side').css('background-color', '#FFFFFF');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2350,13 +2422,24 @@ function hideMore() {
|
||||
|
||||
function handleLottieUpload() {
|
||||
var filething = $('#filepick3').get(0).files;
|
||||
if (!filething || filething.length == 0) {
|
||||
return;
|
||||
}
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (event) {
|
||||
newLottieAnimation(
|
||||
artboard.get('left') + artboard.get('width') / 2,
|
||||
artboard.get('top') + artboard.get('height') / 2,
|
||||
event.target.result
|
||||
);
|
||||
try {
|
||||
newLottieAnimation(
|
||||
artboard.get('left') + artboard.get('width') / 2,
|
||||
artboard.get('top') + artboard.get('height') / 2,
|
||||
event.target.result
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('That does not look like a valid Lottie file');
|
||||
}
|
||||
};
|
||||
reader.onerror = function () {
|
||||
alert('Could not read the file');
|
||||
};
|
||||
reader.readAsDataURL(filething.item(0));
|
||||
}
|
||||
|
||||
+126
-17
@@ -596,7 +596,12 @@ function writeEBML(buffer, bufferFileOffset, ebml) {
|
||||
buffer.writeEBMLVarInt(4); // Size field
|
||||
ebml.dataOffset = buffer.pos + bufferFileOffset;
|
||||
buffer.writeFloatBE(ebml.data.value);
|
||||
} else if (ebml.data instanceof Uint8Array) {
|
||||
} else if (
|
||||
ebml.data instanceof Uint8Array ||
|
||||
(ArrayBuffer.isView(ebml.data) &&
|
||||
ebml.data.BYTES_PER_ELEMENT === 1)) {
|
||||
// isView as well as instanceof: a byte array that crossed a realm
|
||||
// boundary (worker, vm context) fails the instanceof check
|
||||
buffer.writeEBMLVarInt(ebml.data.byteLength); // Size field
|
||||
ebml.dataOffset = buffer.pos + bufferFileOffset;
|
||||
buffer.writeBytes(ebml.data);
|
||||
@@ -630,7 +635,8 @@ function writeEBML(buffer, bufferFileOffset, ebml) {
|
||||
*/
|
||||
let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
||||
return function(options) {
|
||||
let MAX_CLUSTER_DURATION_MSEC = 5000000, DEFAULT_TRACK_NUMBER = 1,
|
||||
let MAX_CLUSTER_DURATION_MSEC = 5000, DEFAULT_TRACK_NUMBER = 1,
|
||||
AUDIO_TRACK_NUMBER = 2,
|
||||
writtenHeader = false, videoWidth = 0, videoHeight = 0,
|
||||
firstTimestampEver = true, earliestTimestamp = 0,
|
||||
|
||||
@@ -649,6 +655,9 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
||||
// (optional)
|
||||
codec: 'VP8', // Codec to write to webm file
|
||||
|
||||
// Optional second track holding Opus audio. Supply:
|
||||
// {sampleRate, channels, codecPrivate: Uint8Array (OpusHead)}
|
||||
audio: null,
|
||||
},
|
||||
|
||||
seekPoints = {
|
||||
@@ -787,9 +796,7 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
||||
}
|
||||
];
|
||||
|
||||
let tracks = {
|
||||
'id': 0x1654ae6b, // Tracks
|
||||
'data': [{
|
||||
let trackEntries = [{
|
||||
'id': 0xae, // TrackEntry
|
||||
'data': [
|
||||
{
|
||||
@@ -868,7 +875,87 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
||||
'data': options.codec
|
||||
},*/
|
||||
]
|
||||
}]
|
||||
}];
|
||||
|
||||
// Optional Opus audio track.
|
||||
// CodecPrivate must be an OpusHead block, and Opus needs the pre-roll
|
||||
// hints or players will start it with audible artefacts.
|
||||
if (options.audio) {
|
||||
trackEntries.push({
|
||||
'id': 0xae, // TrackEntry
|
||||
'data': [
|
||||
{
|
||||
'id': 0xd7, // TrackNumber
|
||||
'data': AUDIO_TRACK_NUMBER
|
||||
},
|
||||
{
|
||||
'id': 0x73c5, // TrackUID
|
||||
'data': AUDIO_TRACK_NUMBER
|
||||
},
|
||||
{
|
||||
'id': 0x83, // TrackType (2 = audio)
|
||||
'data': 2
|
||||
},
|
||||
{
|
||||
'id': 0x9c, // FlagLacing
|
||||
'data': 0
|
||||
},
|
||||
{
|
||||
'id': 0x22b59c, // Language
|
||||
'data': 'und'
|
||||
},
|
||||
{
|
||||
'id': 0xb9, // FlagEnabled
|
||||
'data': 1
|
||||
},
|
||||
{
|
||||
'id': 0x88, // FlagDefault
|
||||
'data': 1
|
||||
},
|
||||
{
|
||||
'id': 0x55aa, // FlagForced
|
||||
'data': 0
|
||||
},
|
||||
{
|
||||
'id': 0x86, // CodecID
|
||||
'data': 'A_OPUS'
|
||||
},
|
||||
{
|
||||
'id': 0x63A2, // CodecPrivate
|
||||
'data': options.audio.codecPrivate
|
||||
},
|
||||
{
|
||||
'id': 0x56AA, // CodecDelay (ns)
|
||||
'data': 6500000
|
||||
},
|
||||
{
|
||||
'id': 0x56BB, // SeekPreRoll (ns)
|
||||
'data': 80000000
|
||||
},
|
||||
{
|
||||
'id': 0xe1, // Audio
|
||||
'data': [
|
||||
{
|
||||
'id': 0xb5, // SamplingFrequency
|
||||
'data': new EBMLFloat64(options.audio.sampleRate)
|
||||
},
|
||||
{
|
||||
'id': 0x9f, // Channels
|
||||
'data': options.audio.channels
|
||||
},
|
||||
{
|
||||
'id': 0x6264, // BitDepth
|
||||
'data': 32
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
let tracks = {
|
||||
'id': 0x1654ae6b, // Tracks
|
||||
'data': trackEntries
|
||||
};
|
||||
|
||||
ebmlSegment = {
|
||||
@@ -881,7 +968,9 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
||||
]
|
||||
};
|
||||
|
||||
let bufferStream = new ArrayBufferDataStream(256);
|
||||
// Has to fit the header, SeekHead, SegmentInfo and every TrackEntry.
|
||||
// 256 was only ever enough for a single video track.
|
||||
let bufferStream = new ArrayBufferDataStream(1024);
|
||||
|
||||
writeEBML(bufferStream, blobBuffer.pos, [ebmlHeader, ebmlSegment]);
|
||||
blobBuffer.write(bufferStream.getAsDataArray());
|
||||
@@ -1036,7 +1125,7 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
||||
* @param {Frame} frame
|
||||
*/
|
||||
function addFrameToCluster(frame) {
|
||||
frame.trackNumber = DEFAULT_TRACK_NUMBER;
|
||||
frame.trackNumber = frame.trackNumber || DEFAULT_TRACK_NUMBER;
|
||||
var time = frame.intime / 1000;
|
||||
if (firstTimestampEver) {
|
||||
earliestTimestamp = time;
|
||||
@@ -1045,19 +1134,30 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
||||
} else {
|
||||
time = time - earliestTimestamp;
|
||||
}
|
||||
lastTimeCode = time;
|
||||
if (clusterDuration == 0) clusterStartTime = time;
|
||||
if (time > lastTimeCode) {
|
||||
lastTimeCode = time;
|
||||
}
|
||||
|
||||
// Start a new cluster on a video keyframe once the current one has
|
||||
// grown past the limit. SimpleBlock timecodes are a signed 16 bit
|
||||
// offset from the cluster, so clusters cannot span more than ~32s.
|
||||
const isVideoKeyframe =
|
||||
frame.trackNumber == DEFAULT_TRACK_NUMBER && frame.type == 'key';
|
||||
if (
|
||||
clusterFrameBuffer.length > 0 && isVideoKeyframe &&
|
||||
time - clusterStartTime >= MAX_CLUSTER_DURATION_MSEC) {
|
||||
flushClusterFrameBuffer();
|
||||
}
|
||||
|
||||
if (clusterFrameBuffer.length === 0) {
|
||||
clusterStartTime = time;
|
||||
}
|
||||
|
||||
// Frame timecodes are relative to the start of their cluster:
|
||||
// frame.timecode = Math.round(clusterDuration);
|
||||
frame.timecode = Math.round(time - clusterStartTime);
|
||||
|
||||
clusterFrameBuffer.push(frame);
|
||||
clusterDuration = frame.timecode + 1;
|
||||
|
||||
if (clusterDuration >= MAX_CLUSTER_DURATION_MSEC) {
|
||||
flushClusterFrameBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1106,24 +1206,33 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
||||
* toDataUrl() on an image yourself.
|
||||
*
|
||||
*/
|
||||
this.addFrame = function(frame) {
|
||||
this.addFrame = function(frame, trackNumber) {
|
||||
if (!writtenHeader) {
|
||||
videoWidth = options.width;
|
||||
videoHeight = options.height;
|
||||
writeHeader();
|
||||
}
|
||||
if (frame.constructor.name == 'EncodedVideoChunk') {
|
||||
const name = frame.constructor.name;
|
||||
if (name == 'EncodedVideoChunk' || name == 'EncodedAudioChunk') {
|
||||
let frameData = new Uint8Array(frame.byteLength);
|
||||
frame.copyTo(frameData);
|
||||
addFrameToCluster({
|
||||
frame: frameData,
|
||||
intime: frame.timestamp,
|
||||
type: frame.type,
|
||||
trackNumber: trackNumber ||
|
||||
(name == 'EncodedAudioChunk' ? AUDIO_TRACK_NUMBER :
|
||||
DEFAULT_TRACK_NUMBER),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Encoded audio for the optional second track
|
||||
this.addAudioChunk = function(chunk) {
|
||||
this.addFrame(chunk, AUDIO_TRACK_NUMBER);
|
||||
};
|
||||
|
||||
/**
|
||||
* Finish writing the video and return a Promise to signal completion.
|
||||
*
|
||||
|
||||
+94
-4
@@ -734,7 +734,7 @@ body {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
}
|
||||
#item-text {
|
||||
.item-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
height: 34px;
|
||||
@@ -1418,7 +1418,8 @@ input[type="number"] {
|
||||
/* Download modal */
|
||||
#download-modal {
|
||||
width: 300px;
|
||||
height: 255px;
|
||||
/* Grows with its content: the frame rate row is hidden for image exports */
|
||||
padding-bottom: 20px;
|
||||
background-color: var(--panel-back);
|
||||
border: 1px solid var(--panel-stroke);
|
||||
border-radius: 5px;
|
||||
@@ -1463,6 +1464,28 @@ input[type="number"] {
|
||||
margin-left: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
#framerate-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
#framerate-row .subheader {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
#framerate {
|
||||
background-color: var(--input-color);
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: var(--main-text-color);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
height: 35px;
|
||||
outline: none;
|
||||
padding-left: 11px;
|
||||
width: calc(100% - 40px);
|
||||
margin-left: 20px;
|
||||
}
|
||||
#framerate:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.magic-radio:checked + label:before {
|
||||
background-color: var(--accent-color) !important;
|
||||
border: 0px !important;
|
||||
@@ -1626,6 +1649,46 @@ layer:nth-child(even) .properties {
|
||||
font-size: 14px;
|
||||
text-indent: 25px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
.layer-handle {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 9px;
|
||||
height: 16px;
|
||||
text-indent: 0;
|
||||
opacity: 0.3;
|
||||
cursor: grab;
|
||||
background-image: radial-gradient(
|
||||
currentColor 1px,
|
||||
transparent 1.5px
|
||||
);
|
||||
background-size: 4px 5px;
|
||||
background-position: 1px 2px;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.layer:hover .layer-handle {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.layer .layer-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.layer-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.layer-handle[draggable="false"] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.15 !important;
|
||||
}
|
||||
.sortable-placeholder {
|
||||
height: 35px;
|
||||
margin-bottom: 2px;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed var(--accent-color);
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 122, 255, 0.12);
|
||||
}
|
||||
.layer-custom-name {
|
||||
background: transparent;
|
||||
@@ -2020,6 +2083,33 @@ layer:nth-child(even) .properties {
|
||||
z-index: 99999999;
|
||||
pointer-events: all;
|
||||
top: 0px;
|
||||
/* Never let the browser start a native drag or a selection on the seekbar */
|
||||
-webkit-user-drag: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
#seekbar:after,
|
||||
#seek-hover {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Same for the other timeline drag targets */
|
||||
.keyframe,
|
||||
.main-row,
|
||||
.row-el,
|
||||
.trim-row,
|
||||
#timeline-handle {
|
||||
-webkit-user-drag: none;
|
||||
touch-action: none;
|
||||
}
|
||||
/* And for the library panel items dragged onto the canvas */
|
||||
.image-grid-item,
|
||||
.video-grid-item,
|
||||
.grid-item,
|
||||
.grid-emoji-item,
|
||||
.add-text,
|
||||
.credit {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
#seekbar:hover {
|
||||
outline: 3px solid rgba(255, 255, 255, 0.1);
|
||||
@@ -2438,10 +2528,10 @@ layer:nth-child(even) .properties {
|
||||
margin-top: -45px;
|
||||
box-shadow: 0px 8px 4px -4px rgb(12 13 26 / 50%);
|
||||
}
|
||||
#filters-header #filters-title {
|
||||
#filters-header .filters-title {
|
||||
margin-top: 0px!important;
|
||||
}
|
||||
#filters-title {
|
||||
.filters-title {
|
||||
color: var(--main-text-color);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
Reference in New Issue
Block a user