diff --git a/examples/slideInText.json5 b/examples/slideInText.json5 new file mode 100644 index 0000000..60085ee --- /dev/null +++ b/examples/slideInText.json5 @@ -0,0 +1,9 @@ +{ + outPath: './slideInText.mp4', + clips: [ + { duration: 3, layers: [ + { type: 'image', path: 'assets/img2.jpg' }, + { type: 'slide-in-text', text: 'Text that slides in', top: 0.93, left: 0.04, color: '#fff', originY: 'bottom', fontSize: 0.05 }, + ] }, + ], +} diff --git a/index.js b/index.js index 258580c..1d80af6 100644 --- a/index.js +++ b/index.js @@ -110,7 +110,7 @@ module.exports = async (config = {}) => { return outLayers; } - if (['title', 'subtitle', 'news-title'].includes(type)) { + if (['title', 'subtitle', 'news-title', 'slide-in-text'].includes(type)) { assert(layer.text, 'Please specify a text'); let { fontFamily } = layer; diff --git a/sources/fabric/fabricFrameSources.js b/sources/fabric/fabricFrameSources.js index 5de188d..33afb7a 100644 --- a/sources/fabric/fabricFrameSources.js +++ b/sources/fabric/fabricFrameSources.js @@ -2,12 +2,14 @@ const { fabric } = require('fabric'); const fileUrl = require('file-url'); const { getRandomGradient, getRandomColors } = require('../../colors'); -const { easeOutExpo } = require('../../transitions'); -const { getPositionProps } = require('../../util'); +const { easeOutExpo, easeInOutCubic } = require('../../transitions'); +const { getPositionProps, getFrameByKeyFrames } = require('../../util'); // http://fabricjs.com/kitchensink +const defaultFontFamily = 'sans-serif'; + const loadImage = async (path) => new Promise((resolve) => fabric.util.loadImage(fileUrl(path), resolve)); function getZoomParams({ progress, zoomDirection, zoomAmount }) { @@ -155,7 +157,7 @@ async function linearGradientFrameSource({ width, height, params }) { } async function subtitleFrameSource({ width, height, params }) { - const { text, textColor = '#ffffff', backgroundColor = 'rgba(0,0,0,0.3)', fontFamily = 'sans-serif', delay = 0, speed = 1 } = params; + const { text, textColor = '#ffffff', backgroundColor = 'rgba(0,0,0,0.3)', fontFamily = defaultFontFamily, delay = 0, speed = 1 } = params; async function onRender(progress, canvas) { const easedProgress = easeOutExpo(Math.max(0, Math.min((progress - delay) * speed, 1))); @@ -227,7 +229,7 @@ async function imageOverlayFrameSource({ params, width, height }) { } async function titleFrameSource({ width, height, params }) { - const { text, textColor = '#ffffff', fontFamily = 'sans-serif', position = 'center', zoomDirection = 'in', zoomAmount = 0.2 } = params; + const { text, textColor = '#ffffff', fontFamily = defaultFontFamily, position = 'center', zoomDirection = 'in', zoomAmount = 0.2 } = params; async function onRender(progress, canvas) { // console.log('progress', progress); @@ -266,7 +268,7 @@ async function titleFrameSource({ width, height, params }) { } async function newsTitleFrameSource({ width, height, params }) { - const { text, textColor = '#ffffff', backgroundColor = '#d02a42', fontFamily = 'sans-serif', delay = 0, speed = 1 } = params; + const { text, textColor = '#ffffff', backgroundColor = '#d02a42', fontFamily = defaultFontFamily, delay = 0, speed = 1 } = params; async function onRender(progress, canvas) { const min = Math.min(width, height); @@ -308,6 +310,73 @@ async function newsTitleFrameSource({ width, height, params }) { return { onRender }; } +async function getFadedObject({ object, progress }) { + const rect = new fabric.Rect({ + left: 0, + width: object.width, + height: object.height, + top: 0, + }); + + rect.set('fill', new fabric.Gradient({ + coords: { + x1: 0, + y1: 0, + x2: object.width, + y2: 0, + }, + colorStops: [ + { offset: Math.max(0, (progress * (1 + 0.2)) - 0.2), color: 'rgba(255,255,255,1)' }, + { offset: Math.min(1, (progress * (1 + 0.2))), color: 'rgba(255,255,255,0)' }, + ], + })); + + const gradientMaskImg = await new Promise((r) => rect.cloneAsImage(r)); + const fadedImage = await new Promise((r) => object.cloneAsImage(r)); + + fadedImage.filters.push(new fabric.Image.filters.BlendImage({ + image: gradientMaskImg, + mode: 'multiply', + })); + + fadedImage.applyFilters(); + + return fadedImage; +} + +async function slideInTextFrameSource({ width, height, params: { text, top = 0.05, left = 0.05, originX = 'left', originY = 'top', fontSize = 0.05, color = '#ffffff', fontFamily = defaultFontFamily } = {} }) { + async function onRender(progress, canvas) { + const fontSizeAbs = Math.round(width * fontSize); + + const textBox = new fabric.Text(text, { + fill: color, + fontFamily, + fontSize: fontSizeAbs, + charSpacing: width * 0.1, + }); + + const { opacity, textSlide } = getFrameByKeyFrames([ + { t: 0.1, props: { opacity: 1, textSlide: 0 } }, + { t: 0.3, props: { opacity: 1, textSlide: 1 } }, + { t: 0.8, props: { opacity: 1, textSlide: 1 } }, + { t: 0.9, props: { opacity: 0, textSlide: 1 } }, + ], progress); + + const fadedObject = await getFadedObject({ object: textBox, progress: easeInOutCubic(textSlide) }); + fadedObject.setOptions({ + originX, + originY, + top: top * height, + left: left * width, + opacity, + }); + + canvas.add(fadedObject); + } + + return { onRender }; +} + async function customFabricFrameSource({ canvas, width, height, params }) { return params.func(({ width, height, fabric, canvas, params })); } @@ -322,4 +391,5 @@ module.exports = { linearGradientFrameSource, imageFrameSource, imageOverlayFrameSource, + slideInTextFrameSource, }; diff --git a/sources/frameSource.js b/sources/frameSource.js index 0a8a534..a65b962 100644 --- a/sources/frameSource.js +++ b/sources/frameSource.js @@ -3,7 +3,7 @@ const pMap = require('p-map'); const { rgbaToFabricImage, createCustomCanvasFrameSource, createFabricFrameSource, createFabricCanvas, renderFabricCanvas } = require('./fabric'); -const { customFabricFrameSource, subtitleFrameSource, titleFrameSource, newsTitleFrameSource, fillColorFrameSource, radialGradientFrameSource, linearGradientFrameSource, imageFrameSource, imageOverlayFrameSource } = require('./fabric/fabricFrameSources'); +const { customFabricFrameSource, subtitleFrameSource, titleFrameSource, newsTitleFrameSource, fillColorFrameSource, radialGradientFrameSource, linearGradientFrameSource, imageFrameSource, imageOverlayFrameSource, slideInTextFrameSource } = require('./fabric/fabricFrameSources'); const createVideoFrameSource = require('./videoFrameSource'); const { createGlFrameSource } = require('./glFrameSource'); @@ -18,6 +18,7 @@ const fabricFrameSources = { 'radial-gradient': radialGradientFrameSource, 'fill-color': fillColorFrameSource, 'news-title': newsTitleFrameSource, + 'slide-in-text': slideInTextFrameSource, }; async function createFrameSource({ clip, clipIndex, width, height, channels, verbose, ffmpegPath, ffprobePath, enableFfmpegLog, framerateStr }) { diff --git a/util.js b/util.js index 5933143..4e4abc4 100644 --- a/util.js +++ b/util.js @@ -1,5 +1,7 @@ const execa = require('execa'); const assert = require('assert'); +const sortBy = require('lodash/sortBy'); + function parseFps(fps) { const match = typeof fps === 'string' && fps.match(/^([0-9]+)\/([0-9]+)$/); @@ -92,6 +94,34 @@ function getPositionProps({ position, width, height }) { return { originX, originY, top, left }; } +function getFrameByKeyFrames(keyframes, progress) { + if (keyframes.length < 2) throw new Error('Keyframes must be at least 2'); + const sortedKeyframes = sortBy(keyframes, 't'); + + // TODO check that max is 1 + // TODO check that all keyframes have all props + // TODO make smarter so user doesn't need to replicate non-changing props + + const invalidKeyframe = sortedKeyframes.find((k, i) => { + if (i === 0) return false; + return k.t === sortedKeyframes[i - 1].t; + }); + if (invalidKeyframe) throw new Error('Invalid keyframe'); + + let prevKeyframe = [...sortedKeyframes].reverse().find((k) => k.t < progress); + // eslint-disable-next-line prefer-destructuring + if (!prevKeyframe) prevKeyframe = sortedKeyframes[0]; + + let nextKeyframe = sortedKeyframes.find((k) => k.t >= progress); + if (!nextKeyframe) nextKeyframe = sortedKeyframes[sortedKeyframes.length - 1]; + + if (nextKeyframe.t === prevKeyframe.t) return prevKeyframe.props; + + const interProgress = (progress - prevKeyframe.t) / (nextKeyframe.t - prevKeyframe.t); + return Object.fromEntries(Object.entries(prevKeyframe.props).map(([propName, prevVal]) => ([propName, prevVal + ((nextKeyframe.props[propName] - prevVal) * interProgress)]))); +} + + module.exports = { parseFps, readVideoFileInfo, @@ -100,4 +130,5 @@ module.exports = { toArrayInteger, readFileStreams, getPositionProps, + getFrameByKeyFrames, };