{
- return {
- element: document.getElementById('nothing'),
- posX: event.pageX - elementOffset.left,
- posY: event.pageY - elementOffset.top,
- };
- },
- });
+ // Make the new layer draggable (html5sortable only wires up the
+ // children that exist when it is initialized)
+ if (typeof initLayerSortable == 'function') {
+ initLayerSortable();
+ }
if (object.selectable == false) {
- $(".layer[data-object='" + object.get('id') + "']").attr(
- 'draggable',
- false
- );
+ $(".layer[data-object='" + object.get('id') + "']")
+ .find('.layer-handle')
+ .attr('draggable', false);
}
}
// Render a property
function renderProp(prop, object) {
var classfreeze = '';
- srcfreeze = 'freeze';
- if (
- $.inArray(
- prop,
- objects.find((x) => x.id == object.get('id')).animate
- ) != -1
- ) {
+ var srcfreeze = 'freeze';
+ const entry = objects.find((x) => x.id == object.get('id'));
+ if (!entry) {
+ return;
+ }
+ if ($.inArray(prop, entry.animate) != -1) {
classfreeze = 'frozen';
srcfreeze = 'frozen';
}
@@ -3218,6 +3189,10 @@ function newLayer(object) {
} else if (object.get('assetType') == 'audio') {
color = '#11C0F7';
}
+ } else if (object.get('type') == 'lottie') {
+ color = '#F1890E';
+ } else {
+ color = '#9211F7';
}
if (
(object.get('assetType') && object.get('assetType') == 'video') ||
@@ -3235,25 +3210,20 @@ function newLayer(object) {
start: 0,
end: object.get('duration'),
});
- if (object.get('duration') < duration) {
- p_keyframes.push({
- start: currenttime,
- end: object.get('duration') + currenttime,
- trimstart: 0,
- trimend: object.get('duration') + currenttime,
- object: object,
- id: object.get('id'),
- });
- } else {
- p_keyframes.push({
- start: currenttime,
- end: duration - currenttime,
- trimstart: 0,
- trimend: duration - currenttime,
- object: object,
- id: object.get('id'),
- });
- }
+ // Clamp to the project end. This used to be `duration - currenttime`,
+ // which shortened every layer added after t=0.
+ const mediaend = Math.min(
+ object.get('duration') + currenttime,
+ duration
+ );
+ p_keyframes.push({
+ start: currenttime,
+ end: mediaend,
+ trimstart: 0,
+ trimend: mediaend,
+ object: object,
+ id: object.get('id'),
+ });
} else {
objects.push({
object: object,
@@ -3264,25 +3234,16 @@ function newLayer(object) {
locked: [],
mask: 'none',
});
- if (object.get('notnew')) {
- p_keyframes.push({
- start: object.get('starttime'),
- end: duration - object.get('starttime'),
- trimstart: 0,
- trimend: duration - currenttime,
- object: object,
- id: object.get('id'),
- });
- } else {
- p_keyframes.push({
- start: currenttime,
- end: duration - currenttime,
- trimstart: 0,
- trimend: duration - currenttime,
- object: object,
- id: object.get('id'),
- });
- }
+ p_keyframes.push({
+ start: object.get('notnew')
+ ? object.get('starttime')
+ : currenttime,
+ end: duration,
+ trimstart: 0,
+ trimend: duration,
+ object: object,
+ id: object.get('id'),
+ });
}
renderLayer(object);
if (
@@ -3370,9 +3331,11 @@ function newLayer(object) {
$(".layer[data-object='" + object.get('id') + "']").addClass(
'layer-selected'
);
- document
- .getElementsByClassName('layer-selected')[0]
- .scrollIntoView();
+ const selectedLayer =
+ document.getElementsByClassName('layer-selected')[0];
+ if (selectedLayer) {
+ selectedLayer.scrollIntoView();
+ }
objects.find((x) => x.id == object.id).animate = [];
animate(false, currenttime);
save();
@@ -3502,6 +3465,10 @@ function loadVideo(src, x, y, center) {
vidSrc.src = src;
vidObj.crossOrigin = 'anonymous';
vidObj.appendChild(vidSrc);
+ vidObj.addEventListener('error', function () {
+ console.error('Could not load video', src);
+ $('#load-video').removeClass('loading-active');
+ });
vidObj.addEventListener('loadeddata', function () {
vidObj.width = this.videoWidth;
vidObj.height = this.videoHeight;
@@ -3547,17 +3514,20 @@ function checkCrop(obj) {
// Perform a crop
function crop(obj) {
- var crop = canvas.getItemById('crop');
+ var cropUI = canvas.getItemById('crop');
+ if (!obj || !cropUI || !cropobj) {
+ return;
+ }
cropobj.setCoords();
- crop.setCoords();
+ cropUI.setCoords();
var cleft =
- crop.get('left') - (crop.get('width') * crop.get('scaleX')) / 2;
+ cropUI.get('left') - (cropUI.get('width') * cropUI.get('scaleX')) / 2;
var ctop =
- crop.get('top') - (crop.get('height') * crop.get('scaleY')) / 2;
+ cropUI.get('top') - (cropUI.get('height') * cropUI.get('scaleY')) / 2;
var height =
- (crop.get('height') / cropobj.get('scaleY')) * crop.get('scaleY');
+ (cropUI.get('height') / cropobj.get('scaleY')) * cropUI.get('scaleY');
var width =
- (crop.get('width') / cropobj.get('scaleX')) * crop.get('scaleX');
+ (cropUI.get('width') / cropobj.get('scaleX')) * cropUI.get('scaleX');
var img_height = cropobj.get('height') * cropobj.get('scaleY');
var img_width = cropobj.get('width') * cropobj.get('scaleX');
var left =
@@ -3603,8 +3573,8 @@ function crop(obj) {
canvas.renderAll();
}
if (obj.get('id') != 'cropped') {
- canvas.remove(crop);
- canvas.remove(canvas.getItemById('overlay'));
+ canvas.remove(cropUI);
+ canvas.remove(canvas.getItemById('crop-overlay'));
canvas.remove(canvas.getItemById('cropped'));
cropping = false;
resetControls();
@@ -3613,7 +3583,7 @@ function crop(obj) {
newKeyframe('scaleX', obj, currenttime, obj.get('scaleX'), true);
newKeyframe('scaleY', obj, currenttime, obj.get('scaleY'), true);
newKeyframe('width', obj, currenttime, obj.get('width'), true);
- newKeyframe('height', obj, currenttime, obj.get('width'), true);
+ newKeyframe('height', obj, currenttime, obj.get('height'), true);
newKeyframe('left', obj, currenttime, obj.get('left'), true);
newKeyframe('top', obj, currenttime, obj.get('top'), true);
$('#properties-overlay').removeClass('properties-disabled');
@@ -3642,7 +3612,8 @@ function overlay() {
height: artboard.height,
fill: 'rgba(0,0,0,0.5)',
selectable: false,
- id: 'overlay',
+ // Not "overlay": that is the artboard's id
+ id: 'crop-overlay',
})
);
}
@@ -3863,6 +3834,10 @@ function loadImage(src, x, y, width, center) {
image.onload = function (img) {
newImage(image, x, y, width, center);
};
+ image.onerror = function () {
+ console.error('Could not load image', src);
+ $('#load-image').removeClass('loading-active');
+ };
image.src = src;
}
@@ -3890,19 +3865,15 @@ function createVideoThumbnail(file, max, seekTo = 0.0, isURL) {
videoPlayer.addEventListener('seeked', () => {
var oc = document.createElement('canvas');
var octx = oc.getContext('2d');
- oc.width = videoPlayer.videoWidth;
- oc.height = videoPlayer.videoheight;
- octx.drawImage(videoPlayer, 0, 0);
if (videoPlayer.videoWidth > videoPlayer.videoHeight) {
+ oc.width = max;
oc.height =
(videoPlayer.videoHeight / videoPlayer.videoWidth) * max;
- oc.width = max;
} else {
+ oc.height = max;
oc.width =
(videoPlayer.videoWidth / videoPlayer.videoHeight) * max;
- oc.height = max;
}
- octx.drawImage(oc, 0, 0, oc.width, oc.height);
octx.drawImage(videoPlayer, 0, 0, oc.width, oc.height);
resolve(oc.toDataURL());
});
@@ -3919,17 +3890,13 @@ function createThumbnail(file, max) {
if (img.width > max) {
var oc = document.createElement('canvas');
var octx = oc.getContext('2d');
- oc.width = img.width;
- oc.height = img.height;
- octx.drawImage(img, 0, 0);
if (img.width > img.height) {
- oc.height = (img.height / img.width) * max;
oc.width = max;
+ oc.height = (img.height / img.width) * max;
} else {
- oc.width = (img.width / img.height) * max;
oc.height = max;
+ oc.width = (img.width / img.height) * max;
}
- octx.drawImage(oc, 0, 0, oc.width, oc.height);
octx.drawImage(img, 0, 0, oc.width, oc.height);
resolve(oc.toDataURL());
} else {
@@ -4291,34 +4258,35 @@ $(document).on('click', '.align-text', alignText);
// Change font
function changeFont() {
var font = $('#font-picker').val();
- if (canvas.getActiveObject().get('assetType')) {
+ const active = canvas.getActiveObject();
+ if (!active || !font) {
+ return;
+ }
+ if (active.get('assetType')) {
WebFont.load({
google: {
families: [font],
},
active: () => {
- var object = canvas.getActiveObject();
- animatedtext
- .find((x) => x.id == object.id)
- .reset(
- animatedtext.find((x) => x.id == object.id).text,
- $.extend(
- animatedtext.find((x) => x.id == object.id).props,
- { fontFamily: font }
- ),
- canvas
- );
+ const text = animatedtext.find((x) => x.id == active.id);
+ if (!text) {
+ return;
+ }
+ text.reset(
+ text.text,
+ $.extend(text.props, { fontFamily: font }),
+ canvas
+ );
save();
},
});
- save();
} else {
WebFont.load({
google: {
families: [font],
},
active: () => {
- canvas.getActiveObject().set('fontFamily', font);
+ active.set('fontFamily', font);
canvas.renderAll();
save();
},
@@ -4396,7 +4364,7 @@ function newTextbox(
strokeDashArray: false,
width: calculateTextWidth(
text,
- fontweight + ' ' + fontsize + 'px Inter'
+ fontweight + ' ' + fontsize + 'px ' + (font || 'Inter')
),
id: 'Text' + layer_count,
shadow: {
@@ -4440,11 +4408,22 @@ function deleteObject(object, def = true) {
});
}
if (object.type == 'image') {
- var temp = files.find((x) => x.name == object.get('id'));
files = $.grep(files, function (a) {
- return a != temp.name;
+ return a.name != object.get('id');
});
}
+ // Release any media element the object was holding on to
+ if (typeof object.getElement === 'function') {
+ const el = object.getElement();
+ if (el && el.tagName == 'VIDEO') {
+ el.pause();
+ el.removeAttribute('src');
+ el.load();
+ }
+ }
+ if (object.get('src') && typeof object.get('src').pause === 'function') {
+ object.get('src').pause();
+ }
$(".layer[data-object='" + object.get('id') + "']").remove();
$('#' + object.get('id')).remove();
keyframes = $.grep(keyframes, function (e) {
@@ -4519,29 +4498,25 @@ function setDuration(length) {
('0' + Math.floor((seconds % 1) * 100)).slice(-2)
);
$('.object-props').each(function () {
+ const p_keyframe = p_keyframes.find(
+ (x) => x.id == $(this).attr('id')
+ );
+ if (!p_keyframe) {
+ return;
+ }
$(this).css(
'width',
- duration / timelinetime -
- p_keyframes.find((x) => x.id == $(this).attr('id')).start /
- timelinetime +
- 'px'
+ duration / timelinetime - p_keyframe.start / timelinetime + 'px'
);
- p_keyframes.find((x) => x.id == $(this).attr('id')).end =
- duration;
- if (
- p_keyframes.find((x) => x.id == $(this).attr('id')).trimend >
- p_keyframes.find((x) => x.id == $(this).attr('id')).end
- ) {
- p_keyframes.find((x) => x.id == $(this).attr('id')).trimend =
- duration;
+ p_keyframe.end = duration;
+ if (p_keyframe.trimend > p_keyframe.end) {
+ p_keyframe.trimend = duration;
$(this)
.find('.trim-row')
.css(
'width',
duration / timelinetime -
- p_keyframes.find((x) => x.id == $(this).attr('id'))
- .trimstart /
- timelinetime +
+ p_keyframe.trimstart / timelinetime +
'px'
);
}
@@ -4578,7 +4553,7 @@ function renderTimeMarkers() {
's
'
);
if (timenumber % modulo != 0) {
- $('.time-number:last-child()').css('opacity', '0');
+ $('#time-numbers .time-number:last-child').css('opacity', '0');
}
timenumber++;
}
@@ -4587,21 +4562,22 @@ function renderTimeMarkers() {
// Change timeline zoom level
function setTimelineZoom(time) {
$('.object-props').each(function () {
+ const p_keyframe = p_keyframes.find(
+ (x) => x.id == $(this).attr('id')
+ );
+ if (!p_keyframe) {
+ return;
+ }
$(this).offset({
left:
- p_keyframes.find((x) => x.id == $(this).attr('id')).start /
- time +
+ p_keyframe.start / time +
$('#inner-timeline').offset().left +
offset_left,
});
$(this).css({ width: ($(this).width() * timelinetime) / time });
$(this)
.find('.trim-row')
- .css({
- left:
- p_keyframes.find((x) => x.id == $(this).attr('id'))
- .trimstart / time,
- });
+ .css({ left: p_keyframe.trimstart / time });
$(this)
.find('.trim-row')
.css({
@@ -4635,95 +4611,18 @@ $(document).on('input', '#timeline-zoom', function () {
});
function removeKeyframe() {
+ if (!selectedkeyframe) {
+ return;
+ }
+ const time = selectedkeyframe.attr('data-time');
+ const id = selectedkeyframe.attr('data-object');
+ const prop = selectedkeyframe.attr('data-property');
+ const names = [prop].concat(KEYFRAME_COUNTERPARTS[prop] || []);
keyframes = $.grep(keyframes, function (e) {
return (
- e.t != selectedkeyframe.attr('data-time') ||
- e.id != selectedkeyframe.attr('data-object') ||
- e.name != selectedkeyframe.attr('data-property')
+ e.t != time || e.id != id || names.indexOf(e.name) == -1
);
});
- if (selectedkeyframe.attr('data-property') == 'left') {
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != selectedkeyframe.attr('data-time') ||
- e.id != selectedkeyframe.attr('data-object') ||
- e.name != 'top'
- );
- });
- } else if (selectedkeyframe.attr('data-property') == 'scaleX') {
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != selectedkeyframe.attr('data-time') ||
- e.id != selectedkeyframe.attr('data-object') ||
- e.name != 'scaleY'
- );
- });
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != selectedkeyframe.attr('data-time') ||
- e.id != selectedkeyframe.attr('data-object') ||
- e.name != 'width'
- );
- });
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != selectedkeyframe.attr('data-time') ||
- e.id != selectedkeyframe.attr('data-object') ||
- e.name != 'height'
- );
- });
- } else if (
- selectedkeyframe.attr('data-property') == 'strokeWidth'
- ) {
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != selectedkeyframe.attr('data-time') ||
- e.id != selectedkeyframe.attr('data-object') ||
- e.name != 'stroke'
- );
- });
- } else if (
- selectedkeyframe.attr('data-property') == 'shadow.color'
- ) {
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != selectedkeyframe.attr('data-time') ||
- e.id != selectedkeyframe.attr('data-object') ||
- e.name != 'shadow.blur'
- );
- });
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != selectedkeyframe.attr('data-time') ||
- e.id != selectedkeyframe.attr('data-object') ||
- e.name != 'shadow.offsetX'
- );
- });
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != selectedkeyframe.attr('data-time') ||
- e.id != selectedkeyframe.attr('data-object') ||
- e.name != 'shadow.offsetY'
- );
- });
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != selectedkeyframe.attr('data-time') ||
- e.id != selectedkeyframe.attr('data-object') ||
- e.name != 'shadow.opacity'
- );
- });
- } else if (
- selectedkeyframe.attr('data-property') == 'charSpacing'
- ) {
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != selectedkeyframe.attr('data-time') ||
- e.id != selectedkeyframe.attr('data-object') ||
- e.name != 'lineHeight'
- );
- });
- }
selectedkeyframe.remove();
$('#keyframe-properties').removeClass('show-properties');
}
@@ -4744,437 +4643,126 @@ function deleteKeyframe() {
}
$(document).on('click', '#delete-keyframe', deleteKeyframe);
+// Properties that are always keyframed together with a leading property
+const KEYFRAME_COUNTERPARTS = {
+ left: ['top'],
+ scaleX: ['scaleY', 'width', 'height'],
+ strokeWidth: ['stroke'],
+ 'shadow.color': [
+ 'shadow.opacity',
+ 'shadow.offsetX',
+ 'shadow.offsetY',
+ 'shadow.blur',
+ ],
+ charSpacing: ['lineHeight'],
+};
+
// Copy keyframes
function copyKeyframes() {
+ if (!Array.isArray(clipboard) || clipboard.length == 0) {
+ return;
+ }
clipboard.sort(function (a, b) {
return a.t - b.t;
});
var inittime = clipboard[0].t;
clipboard.forEach(function (keyframe) {
+ const object = canvas.getItemById(keyframe.id);
+ if (!object) {
+ return;
+ }
var newtime = keyframe.t - inittime + currenttime;
newKeyframe(
keyframe.name,
- canvas.getItemById(keyframe.id),
+ object,
newtime,
keyframe.value,
true
);
- var keyprop = keyframe.name;
- if (keyprop == 'left') {
+ // Counterpart properties are stored as separate keyframes at the same
+ // time; copy whichever of them actually exist.
+ const counterparts = KEYFRAME_COUNTERPARTS[keyframe.name] || [];
+ counterparts.forEach(function (name) {
const keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == keyframe.t && e.id == keyframe.id && e.name == 'top'
- );
- });
- newKeyframe(
- 'top',
- canvas.getItemById(keyframe.id),
- newtime,
- keyarr2[0].value,
- true
- );
- } else if (keyprop == 'scaleX') {
- var keyarr2 = $.grep(keyframes, function (e) {
return (
e.t == keyframe.t &&
e.id == keyframe.id &&
- e.name == 'scaleY'
- );
- });
- newKeyframe(
- 'scaleY',
- canvas.getItemById(keyframe.id),
- newtime,
- keyarr2[0].value,
- true
- );
- var keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == keyframe.t &&
- e.id == keyframe.id &&
- e.name == 'width'
+ e.name == name
);
});
if (keyarr2.length > 0) {
- newKeyframe(
- 'width',
- canvas.getItemById(keyframe.id),
- newtime,
- keyarr2[0].value,
- true
- );
+ newKeyframe(name, object, newtime, keyarr2[0].value, true);
}
- var keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == keyframe.t &&
- e.id == keyframe.id &&
- e.name == 'height'
- );
- });
- if (keyarr2.length > 0) {
- newKeyframe(
- 'height',
- canvas.getItemById(keyframe.id),
- newtime,
- keyarr2[0].value,
- true
- );
- }
- } else if (keyprop == 'strokeWidth') {
- const keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == keyframe.t &&
- e.id == keyframe.id &&
- e.name == 'stroke'
- );
- });
- newKeyframe(
- 'stroke',
- canvas.getItemById(keyframe.id),
- newtime,
- keyarr2[0].value,
- true
- );
- } else if (keyprop == 'charSpacing') {
- const keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == keyframe.t &&
- e.id == keyframe.id &&
- e.name == 'lineHeight'
- );
- });
- newKeyframe(
- 'lineHeight',
- canvas.getItemByid(keyframe.id),
- newtime,
- keyarr2[0].value,
- true
- );
- } else if (keyprop == 'shadow.color') {
- var keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == keyframe.t &&
- e.id == keyframe.id &&
- e.name == 'shadow.opacity'
- );
- });
- newKeyframe(
- 'shadow.opacity',
- canvas.getItemById(keyframe.id),
- newtime,
- keyarr2[0].value,
- true
- );
- var keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == keyframe.t &&
- e.id == keyframe.id &&
- e.name == 'shadow.offsetX'
- );
- });
- newKeyframe(
- 'shadow.offsetX',
- canvas.getItemById(keyframe.id),
- newtime,
- keyarr2[0].value,
- true
- );
- var keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == keyframe.t &&
- e.id == keyframe.id &&
- e.name == 'shadow.offsetY'
- );
- });
-
- newKeyframe(
- 'shadow.offsetY',
- canvas.getItemById(keyframe.id),
- newtime,
- keyarr2[0].value,
- true
- );
- var keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == keyframe.t &&
- e.id == keyframe.id &&
- e.name == 'shadow.blur'
- );
- });
- newKeyframe(
- 'shadow.blur',
- canvas.getItemById(keyframe.id),
- newtime,
- keyarr2[0].value,
- true
- );
- }
- save();
+ });
});
+ save();
}
-// Update keyframe (after dragging)
-function updateKeyframe(drag, newval, offset) {
- var time = parseFloat(
- (drag.position().left * timelinetime).toFixed(1)
- );
+// Update keyframe (after dragging, or when re-stamping it at the playhead)
+function updateKeyframe(drag, newval) {
const keyprop = drag.attr('data-property');
const keytime = drag.attr('data-time');
+ const objectid = drag.attr('data-object');
const keyarr = $.grep(keyframes, function (e) {
return (
e.t == parseFloat(keytime) &&
- e.id == drag.attr('data-object') &&
+ e.id == objectid &&
e.name == keyprop
);
});
+ if (keyarr.length == 0) {
+ return;
+ }
const keyobj = canvas.getItemById(keyarr[0].id);
- time =
- parseFloat(
- p_keyframes.find((x) => x.id == keyobj.get('id')).start
- ) + time;
- if (newval) {
- time = currenttime;
+ const p_keyframe = p_keyframes.find((x) => x.id == objectid);
+ if (!keyobj || !p_keyframe) {
+ return;
}
- var keyval = keyarr[0].value;
- if (newval) {
- if (keyprop == 'shadow.color') {
- keyval = keyobj.shadow.color;
- } else if (keyprop == 'volume') {
- keyval = parseFloat($('#object-volume input').val() / 200);
- } else {
- keyval = keyobj.get(keyprop);
- }
- } else if (keyprop == 'left') {
- keyval = keyval + artboard.get('left');
- }
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != parseFloat(keytime) ||
- e.id != drag.attr('data-object') ||
- e.name != keyprop
- );
- });
- newKeyframe(keyprop, keyobj, time, keyval, false);
- if (keyprop == 'left') {
- const keyarr2 = $.grep(keyframes, function (e) {
+ // data-time is always absolute; drag.position() is relative to the layer
+ // row, which is itself offset by the layer start.
+ const time = newval
+ ? currenttime
+ : parseFloat(p_keyframe.start) +
+ parseFloat((drag.position().left * timelinetime).toFixed(1));
+
+ const names = [keyprop].concat(
+ KEYFRAME_COUNTERPARTS[keyprop] || []
+ );
+ names.forEach(function (name) {
+ const arr = $.grep(keyframes, function (e) {
return (
e.t == parseFloat(keytime) &&
- e.id == drag.attr('data-object') &&
- e.name == 'top'
+ e.id == objectid &&
+ e.name == name
);
});
- var keyval2 = keyarr2[0].value + artboard.get('top');
+ if (arr.length == 0) {
+ return;
+ }
+ let value = arr[0].value;
if (newval) {
- keyval2 = canvas.getItemById(keyarr2[0].id).get('top');
- }
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != parseFloat(keytime) ||
- e.id != drag.attr('data-object') ||
- e.name != 'top'
- );
- });
- newKeyframe('top', keyobj, time, keyval2, false);
- } else if (keyprop == 'scaleX') {
- var keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == parseFloat(keytime) &&
- e.id == drag.attr('data-object') &&
- e.name == 'scaleY'
- );
- });
- var keyval2 = keyarr2[0].value;
- if (newval) {
- keyval2 = canvas.getItemById(keyarr2[0].id).get('scaleY');
- }
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != parseFloat(keytime) ||
- e.id != drag.attr('data-object') ||
- e.name != 'scaleY'
- );
- });
- newKeyframe('scaleY', keyobj, time, keyval2, false);
- var keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == parseFloat(keytime) &&
- e.id == drag.attr('data-object') &&
- e.name == 'width'
- );
- });
- if (keyarr2.length > 0) {
- var keyval2 = keyarr2[0].value;
- if (newval) {
- keyval2 = canvas.getItemById(keyarr2[0].id).get('width');
+ if (name == 'volume') {
+ value = parseFloat($('#object-volume input').val()) / 200;
+ } else {
+ value = getPropValue(keyobj, name);
}
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != parseFloat(keytime) ||
- e.id != drag.attr('data-object') ||
- e.name != 'width'
- );
- });
- newKeyframe('width', keyobj, time, keyval2, false);
- }
- var keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == parseFloat(keytime) &&
- e.id == drag.attr('data-object') &&
- e.name == 'height'
- );
- });
- if (keyarr2.length > 0) {
- var keyval2 = keyarr2[0].value;
- if (newval) {
- keyval2 = canvas.getItemById(keyarr2[0].id).get('height');
- }
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != parseFloat(keytime) ||
- e.id != drag.attr('data-object') ||
- e.name != 'height'
- );
- });
- newKeyframe('height', keyobj, time, keyval2, false);
- }
- } else if (keyprop == 'strokeWidth') {
- const keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == parseFloat(keytime) &&
- e.id == drag.attr('data-object') &&
- e.name == 'stroke'
- );
- });
- var keyval2 = keyarr2[0].value;
- if (newval) {
- keyval2 = canvas.getItemById(keyarr2[0].id).get('stroke');
+ } else if (name == 'left') {
+ // newKeyframe stores left/top relative to the artboard
+ value = value + artboard.get('left');
+ } else if (name == 'top') {
+ value = value + artboard.get('top');
}
keyframes = $.grep(keyframes, function (e) {
return (
e.t != parseFloat(keytime) ||
- e.id != drag.attr('data-object') ||
- e.name != 'stroke'
+ e.id != objectid ||
+ e.name != name
);
});
- newKeyframe('stroke', keyobj, time, keyval2, false);
- } else if (keyprop == 'charSpacing') {
- const keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == parseFloat(keytime) &&
- e.id == drag.attr('data-object') &&
- e.name == 'lineHeight'
- );
- });
- var keyval2 = keyarr2[0].value;
- if (newval) {
- keyval2 = canvas.getItemById(keyarr2[0].id).get('lineHeight');
- }
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != parseFloat(keytime) ||
- e.id != drag.attr('data-object') ||
- e.name != 'lineHeight'
- );
- });
- newKeyframe('lineHeight', keyobj, time, keyval2, false);
- } else if (keyprop == 'shadow.color') {
- var keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == parseFloat(keytime) &&
- e.id == drag.attr('data-object') &&
- e.name == 'shadow.opacity'
- );
- });
- var keyval2 = keyarr2[0].value;
- if (newval) {
- keyval2 = canvas.getItemById(keyarr2[0].id).shadow.opacity;
- }
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != parseFloat(keytime) ||
- e.id != drag.attr('data-object') ||
- e.name != 'shadow.opacity'
- );
- });
- newKeyframe('shadow.opacity', keyobj, time, keyval2, false);
- var keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == parseFloat(keytime) &&
- e.id == drag.attr('data-object') &&
- e.name == 'shadow.offsetX'
- );
- });
- var keyval2 = keyarr2[0].value;
- if (newval) {
- keyval2 = canvas.getItemById(keyarr2[0].id).shadow.offsetX;
- }
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != parseFloat(keytime) ||
- e.id != drag.attr('data-object') ||
- e.name != 'shadow.offsetX'
- );
- });
- newKeyframe('shadow.offsetX', keyobj, time, keyval2, false);
- var keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == parseFloat(keytime) &&
- e.id == drag.attr('data-object') &&
- e.name == 'shadow.offsetY'
- );
- });
- var keyval2 = keyarr2[0].value;
- if (newval) {
- keyval2 = canvas.getItemById(keyarr2[0].id).shadow.offsetY;
- }
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != parseFloat(keytime) ||
- e.id != drag.attr('data-object') ||
- e.name != 'shadow.offsetY'
- );
- });
- newKeyframe('shadow.offsetY', keyobj, time, keyval2, false);
- var keyarr2 = $.grep(keyframes, function (e) {
- return (
- e.t == parseFloat(keytime) &&
- e.id == drag.attr('data-object') &&
- e.name == 'shadow.blur'
- );
- });
- var keyval2 = keyarr2[0].value;
- if (newval) {
- keyval2 = canvas.getItemById(keyarr2[0].id).shadow.blur;
- }
- keyframes = $.grep(keyframes, function (e) {
- return (
- e.t != parseFloat(keytime) ||
- e.id != drag.attr('data-object') ||
- e.name != 'shadow.blur'
- );
- });
- newKeyframe('shadow.blur', keyobj, time, keyval2, false);
- }
- if (offset) {
- drag.attr('data-time', time);
- } else {
- drag.attr(
- 'data-time',
- time + p_keyframes.find((x) => x.id == keyarr[0].id).start
- );
- }
- keyframes.sort(function (a, b) {
- if (a.id.indexOf('Group') >= 0 && b.id.indexOf('Group') == -1) {
- return 1;
- } else if (
- b.id.indexOf('Group') >= 0 &&
- a.id.indexOf('Group') == -1
- ) {
- return -1;
- } else {
- return 0;
- }
+ newKeyframe(name, keyobj, time, value, false);
});
+ drag.attr('data-time', time);
+ sortKeyframes();
}
function keyframeSnap(drag) {
@@ -5193,11 +4781,12 @@ function keyframeSnap(drag) {
});
$('#line-snap').addClass('line-active');
} else {
+ let snapped = false;
drag
.parent()
.parent()
.find('.keyframe')
- .each(function (index) {
+ .each(function () {
if (!drag.is($(this))) {
if (
drag.offset().left > $(this).offset().left - 5 &&
@@ -5212,17 +4801,67 @@ function keyframeSnap(drag) {
height: drag.parent().parent().height(),
});
$('#line-snap').addClass('line-active');
+ snapped = true;
return false;
}
}
- if (index == $('.keyframe').length - 1) {
- $('#line-snap').removeClass('line-active');
- }
});
+ if (!snapped) {
+ $('#line-snap').removeClass('line-active');
+ }
}
}
}
+// Shared plumbing for the timeline drags (seekbar, keyframes, layer bars,
+// timeline resize handle). Captures the pointer so the matching pointerup
+// always comes back to us, and tears the drag down on pointercancel, on a
+// stray native dragstart, or when the window loses focus. Without this a
+// swallowed mouseup leaves the dragged element glued to the cursor.
+function bindPointerDrag(e, el, onMove, onEnd) {
+ var pointerId =
+ e.originalEvent && e.originalEvent.pointerId != undefined
+ ? e.originalEvent.pointerId
+ : null;
+ if (pointerId != null && el && el.setPointerCapture) {
+ try {
+ el.setPointerCapture(pointerId);
+ } catch (err) {}
+ }
+ function end(ev) {
+ // Ignore another pointer going up while this one is still dragging
+ if (
+ ev &&
+ ev.originalEvent &&
+ pointerId != null &&
+ ev.originalEvent.pointerId != undefined &&
+ ev.originalEvent.pointerId != pointerId
+ ) {
+ return;
+ }
+ $(document)
+ .off('pointermove', onMove)
+ .off('pointerup', end)
+ .off('pointercancel', end)
+ .off('dragstart', end);
+ window.removeEventListener('blur', end);
+ if (pointerId != null && el && el.releasePointerCapture) {
+ try {
+ el.releasePointerCapture(pointerId);
+ } catch (err) {}
+ }
+ onEnd(ev);
+ }
+ $(document)
+ .on('pointermove', onMove)
+ .on('pointerup', end)
+ .on('pointercancel', end)
+ .on('dragstart', end);
+ // Native listener on purpose: jQuery routes blur through its focus special
+ // event, which defers the handler and can drop it entirely
+ window.addEventListener('blur', end);
+}
+
// Dragging a keyframe
function dragKeyframe(e) {
if (e.which == 3) {
@@ -5243,8 +4882,9 @@ function dragKeyframe(e) {
});
$(this).addClass('keyframe-selected');
} else {
+ const el = this;
shiftkeys = $.grep(shiftkeys, function (e) {
- return e.keyframe != this;
+ return e.keyframe !== el;
});
$(this).removeClass('keyframe-selected');
}
@@ -5281,9 +4921,6 @@ function dragKeyframe(e) {
}
}
function releasedKeyframe(e) {
- $('body')
- .off('mousemove', draggingKeyframe)
- .off('mouseup', releasedKeyframe);
$('#line-snap').removeClass('line-active');
if (move) {
if (shiftkeys.length == 0) {
@@ -5321,18 +4958,16 @@ function dragKeyframe(e) {
}
});
}
- } else if (!e.shiftDown) {
+ } else if (e && e.type == 'pointerup' && !e.shiftKey) {
keyframeProperties(inst);
}
move = false;
$('.line-active').removeClass('line-active');
save();
}
- $('body')
- .on('mouseup', releasedKeyframe)
- .on('mousemove', draggingKeyframe);
+ bindPointerDrag(e, this, draggingKeyframe, releasedKeyframe);
}
-$(document).on('mousedown', '.keyframe', dragKeyframe);
+$(document).on('pointerdown', '.keyframe', dragKeyframe);
// Render current time in the playback area
function renderTime() {
@@ -5382,6 +5017,10 @@ function dragSeekBar(e) {
if (e.which == 3) {
return false;
}
+ // Stop the browser from turning the press into a native drag-and-drop.
+ // When it does, the mouseup is swallowed, released() never runs and the
+ // seekbar stays glued to the pointer.
+ e.preventDefault();
var drag = $(this);
var pageX = e.pageX;
var offset = $(this).offset();
@@ -5425,7 +5064,6 @@ function dragSeekBar(e) {
renderTime();
}
function released(e) {
- $('body').off('mousemove', dragging).off('mouseup', released);
updateTime(drag, false);
seeking = false;
if (tempselection && tempselection.type != 'activeSelection') {
@@ -5433,9 +5071,17 @@ function dragSeekBar(e) {
}
updatePanelValues();
}
- $('body').on('mouseup', released).on('mousemove', dragging);
+ bindPointerDrag(e, this, dragging, released);
}
-$(document).on('mousedown', '#seekbar', dragSeekBar);
+$(document).on('pointerdown', '#seekbar', dragSeekBar);
+// The timeline drag targets must never become native drag sources
+$(document).on(
+ 'dragstart',
+ '#seekbar, .keyframe, .main-row, .row-el, .trim-row, #timeline-handle',
+ function (e) {
+ e.preventDefault();
+ }
+);
// Dragging layer horizontally
function dragObjectProps(e) {
@@ -5562,7 +5208,7 @@ function dragObjectProps(e) {
setTimelineZoom(timelinetime);
}
drag.find('.keyframe').each(function () {
- updateKeyframe($(this), false, true);
+ updateKeyframe($(this), false);
});
animate(false, currenttime);
} else if (trim == 'left') {
@@ -5605,7 +5251,6 @@ function dragObjectProps(e) {
}
}
function released(e) {
- $('body').off('mousemove', dragging).off('mouseup', released);
if (opened) {
$(".layer[data-object='" + drag.attr('id') + "']")
.find('.properties')
@@ -5621,9 +5266,9 @@ function dragObjectProps(e) {
animate(false, currenttime);
save();
}
- $('body').on('mouseup', released).on('mousemove', dragging);
+ bindPointerDrag(e, this, dragging, released);
}
-$(document).on('mousedown', '.main-row', dragObjectProps);
+$(document).on('pointerdown', '.main-row', dragObjectProps);
function resetHeight() {
var top = $(window).height() - oldtimelinepos - 92;
@@ -5654,31 +5299,29 @@ function resetHeight() {
// Dragging timeline vertically
function dragTimeline(e) {
- const disableselect = (e) => {
- return false
- }
- document.onselectstart = disableselect
- document.onmousedown = disableselect
-
- oldtimelinepos = e.pageY;
if (e.which == 3) {
return false;
}
+ // Suppress text selection for the duration of the drag only - leaving these
+ // handlers installed kills mousedown for the whole document.
+ const disableselect = function () {
+ return false;
+ };
+ const previousSelectStart = document.onselectstart;
+ document.onselectstart = disableselect;
+
+ oldtimelinepos = e.pageY;
function draggingKeyframe(e) {
oldtimelinepos = e.pageY;
resetHeight(e);
}
function releasedKeyframe(e) {
- $('body')
- .off('mousemove', draggingKeyframe)
- .off('mouseup', releasedKeyframe);
+ document.onselectstart = previousSelectStart || null;
}
- $('body')
- .on('mouseup', releasedKeyframe)
- .on('mousemove', draggingKeyframe);
+ bindPointerDrag(e, this, draggingKeyframe, releasedKeyframe);
}
-$(document).on('mousedown', '#timeline-handle', dragTimeline);
+$(document).on('pointerdown', '#timeline-handle', dragTimeline);
oldtimelinepos = $(window).height() - 92 - $('#timearea').height();
@@ -5749,6 +5392,9 @@ function keyframeProperties(inst) {
e.name == selectedkeyframe.attr('data-property')
);
});
+ if (keyarr.length == 0) {
+ return;
+ }
$('#easing select').val(keyarr[0].easing);
$('#easing select').niceSelect('update');
popup.css({
@@ -5762,108 +5408,28 @@ function keyframeProperties(inst) {
// Apply easing to keyframe
function applyEasing() {
- var keyarr = keyframes.filter(function (e) {
- return (
- e.t == selectedkeyframe.attr('data-time') &&
- e.id == selectedkeyframe.attr('data-object') &&
- e.name == selectedkeyframe.attr('data-property')
- );
- });
- keyarr[0].easing = $(this).attr('data-value');
- if (selectedkeyframe.attr('data-property') == 'left') {
- var keyarr = keyframes.filter(function (e) {
- return (
- e.t == selectedkeyframe.attr('data-time') &&
- e.id == selectedkeyframe.attr('data-object') &&
- e.name == 'top'
- );
- });
- keyarr[0].easing = $('#easing select').val();
- } else if (selectedkeyframe.attr('data-property') == 'scaleX') {
- var keyarr = keyframes.filter(function (e) {
- return (
- e.t == selectedkeyframe.attr('data-time') &&
- e.id == selectedkeyframe.attr('data-object') &&
- e.name == 'scaleY'
- );
- });
- keyarr[0].easing = $('#easing select').val();
- var keyarr = keyframes.filter(function (e) {
- return (
- e.t == selectedkeyframe.attr('data-time') &&
- e.id == selectedkeyframe.attr('data-object') &&
- e.name == 'width'
- );
- });
- keyarr[0].easing = $('#easing select').val();
- var keyarr = keyframes.filter(function (e) {
- return (
- e.t == selectedkeyframe.attr('data-time') &&
- e.id == selectedkeyframe.attr('data-object') &&
- e.name == 'height'
- );
- });
- keyarr[0].easing = $('#easing select').val();
- } else if (
- selectedkeyframe.attr('data-property') == 'strokeWidth'
- ) {
- var keyarr = keyframes.filter(function (e) {
- return (
- e.t == selectedkeyframe.attr('data-time') &&
- e.id == selectedkeyframe.attr('data-object') &&
- e.name == 'stroke'
- );
- });
- keyarr[0].easing = $('#easing select').val();
- } else if (
- selectedkeyframe.attr('data-property') == 'shadow.color'
- ) {
- var keyarr = keyframes.filter(function (e) {
- return (
- e.t == selectedkeyframe.attr('data-time') &&
- e.id == selectedkeyframe.attr('data-object') &&
- e.name == 'shadow.opacity'
- );
- });
- keyarr[0].easing = $('#easing select').val();
- var keyarr = keyframes.filter(function (e) {
- return (
- e.t == selectedkeyframe.attr('data-time') &&
- e.id == selectedkeyframe.attr('data-object') &&
- e.name == 'shadow.offsetX'
- );
- });
- keyarr[0].easing = $('#easing select').val();
- var keyarr = keyframes.filter(function (e) {
- return (
- e.t == selectedkeyframe.attr('data-time') &&
- e.id == selectedkeyframe.attr('data-object') &&
- e.name == 'shadow.offsetY'
- );
- });
- keyarr[0].easing = $('#easing select').val();
- var keyarr = keyframes.filter(function (e) {
- return (
- e.t == selectedkeyframe.attr('data-time') &&
- e.id == selectedkeyframe.attr('data-object') &&
- e.name == 'shadow.blur'
- );
- });
- keyarr[0].easing = $('#easing select').val();
- } else if (
- selectedkeyframe.attr('data-property') == 'charSpacing'
- ) {
- var keyarr = keyframes.filter(function (e) {
- return (
- e.t == selectedkeyframe.attr('data-time') &&
- e.id == selectedkeyframe.attr('data-object') &&
- e.name == 'lineHeight'
- );
- });
- keyarr[0].easing = $('#easing select').val();
+ if (!selectedkeyframe) {
+ return;
}
+ // Read the clicked item, not the underlying
: nice-select has not
+ // necessarily written the value back yet on mouseup.
+ const easing = $(this).attr('data-value');
+ const time = selectedkeyframe.attr('data-time');
+ const id = selectedkeyframe.attr('data-object');
+ const prop = selectedkeyframe.attr('data-property');
+ const names = [prop].concat(KEYFRAME_COUNTERPARTS[prop] || []);
+ names.forEach(function (name) {
+ keyframes
+ .filter(function (e) {
+ return e.t == time && e.id == id && e.name == name;
+ })
+ .forEach(function (e) {
+ e.easing = easing;
+ });
+ });
$('#keyframe-properties').removeClass('show-properties');
selectedkeyframe.removeClass('keyframe-selected');
+ animate(false, currenttime);
save();
}
$(document).on('mouseup', '#easing li', applyEasing);
diff --git a/src/js/init.js b/src/js/init.js
index 44e0f9c..6505b28 100644
--- a/src/js/init.js
+++ b/src/js/init.js
@@ -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
diff --git a/src/js/lottie.js b/src/js/lottie.js
index 3af1e13..b974351 100644
--- a/src/js/lottie.js
+++ b/src/js/lottie.js
@@ -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,
diff --git a/src/js/recorder.js b/src/js/recorder.js
index 8e7a99b..0964d7b 100644
--- a/src/js/recorder.js
+++ b/src/js/recorder.js
@@ -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();
- */
diff --git a/src/js/render.js b/src/js/render.js
new file mode 100644
index 0000000..00ded6c
--- /dev/null
+++ b/src/js/render.js
@@ -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
+ }
+ }
+ }
+}
diff --git a/src/js/text.js b/src/js/text.js
index 5204843..65f4d9c 100644
--- a/src/js/text.js
+++ b/src/js/text.js
@@ -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;
+ }
}
}
diff --git a/src/js/ui.js b/src/js/ui.js
index 10f9912..e70b222 100644
--- a/src/js/ui.js
+++ b/src/js/ui.js
@@ -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(
""
+ "'>
"
);
}
});
@@ -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 +
"" +
item.duration +
- '