diff --git a/README.md b/README.md index 8b90f9a..23473b7 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Edit specs are JavaScript / JSON objects describing the whole edit operation wit layerType: { 'fill-color': { color: '#ff6666', - } + } // ...more per-layer-type defaults }, }, @@ -158,7 +158,7 @@ Edit specs are JavaScript / JSON objects describing the whole edit operation wit | `defaults.duration` | `--clip-duration` | Set default clip duration for clips that don't have an own duration | `4` | sec | | `defaults.transition` | | An object `{ name, duration }` describing the default transition. Set to **null** to disable transitions | | | | `defaults.transition.duration` | `--transition-duration` | Default transition duration | `0.5` | sec | -| `defaults.transition.name` | `--transition-name` | Default transition type. See **Transition types** | `random` | | +| `defaults.transition.name` | `--transition-name` | Default transition type. See [Transition types](#transition-types) | `random` | | | `clips[]` | | List of clip objects that will be played in sequence. Each clip can have one or more layers. | | | | `clips[].duration` | | Clip duration. See `defaults.duration`. If unset, the clip duration will be that of the **first video layer**. | `defaults.duration` | | | `clips[].transition` | | Specify transition at the **end** of this clip. See `defaults.transition` | `defaults.transition` | | @@ -201,19 +201,32 @@ Audio layers will be mixed together. If `cutFrom`/`cutTo` is set, the resulting #### Layer type 'image' +Full screen image (auto letterboxed) + | Parameter | Description | Default | | |-|-|-|-| | `path` | Path to image file | | | -| `zoomDirection` | Zoom direction for Ken Burns effect: `in`, `out` or `null` to disable | `in` | | -| `zoomAmount` | Zoom amount for Ken Burns effect | `0.1` | | + +See also See [Ken Burns parameters](#ken-burns-parameters). + +#### Layer type 'image-overlay' + +Image overlay with a custom position on the screen. + +| Parameter | Description | Default | | +|-|-|-|-| +| `path` | Path to image file | | | +| `position` | See [Position parameter](#position-parameter) | | | + +See also [Ken Burns parameters](#ken-burns-parameters). #### Layer type 'title' - `fontPath` - See `defaults.layer.fontPath` - `text` - Title text to show, keep it short - `textColor` - default `#ffffff` -- `position` - One of either: - - `top`, `bottom` or `center` - vertical position - - An object `{ x, y, originX = 'left', originY = 'top' }`, where `{ x: 0, y: 0 }` is the upper left corner of the screen, and `{ x: 1, y: 1 }` is the lower right corner. `originX` and `originY` are optional, and specify the position origin of the text object. +- `position` - See **Positions** + +See also [Ken Burns parameters](#ken-burns-parameters) #### Layer type 'subtitle' - `fontPath` - See `defaults.layer.fontPath` @@ -261,6 +274,21 @@ Loads a GLSL shader. See [gl.json5](https://github.com/mifi/editly/blob/master/e - `fragmentPath` - `vertexPath` (optional) +### Position parameter + +Certain layers support the position parameter + +`position` can be one of either: + - `top`, `bottom` or `center` - vertical position (horizontally centered) + - An object `{ x, y, originX = 'left', originY = 'top' }`, where `{ x: 0, y: 0 }` is the upper left corner of the screen, and `{ x: 1, y: 1 }` is the lower right corner, `x` is relative to video width, `y` to height. `originX` and `originY` are optional, and specify the position's origin (anchor position) of the object. + +### Ken Burns parameters + +| Parameter | Description | Default | | +|-|-|-|-| +| `zoomDirection` | Zoom direction for Ken Burns effect: `in`, `out` or `null` to disable | | | +| `zoomAmount` | Zoom amount for Ken Burns effect | `0.1` | | + ## Troubleshooting - If you get `Error: The specified module could not be found.`, try: `npm un -g editly && npm i -g --build-from-source editly` (see [#15](https://github.com/mifi/editly/issues/15)) diff --git a/examples/subtitle.json5 b/examples/subtitle.json5 index 0e31209..5641fe3 100644 --- a/examples/subtitle.json5 +++ b/examples/subtitle.json5 @@ -12,7 +12,7 @@ ] }, { duration: 2, layers: [ { type: 'fill-color' }, - { type: 'title', position: { x: 0, y: 1, originY: 'bottom' }, text: 'Custom position' }, + { type: 'title', position: { x: 0, y: 1, originY: 'bottom' }, text: 'Custom position', zoomDirection: null }, ] }, ], } \ No newline at end of file diff --git a/index.js b/index.js index 8e8f72a..532715f 100644 --- a/index.js +++ b/index.js @@ -70,7 +70,7 @@ module.exports = async (config = {}) => { const { type, ...restLayer } = layer; // https://github.com/mifi/editly/issues/39 - if (type === 'image') { + if (['image', 'image-overlay'].includes(type)) { await assertFileExists(restLayer.path); } else if (type === 'gl') { await assertFileExists(restLayer.fragmentPath); @@ -78,7 +78,7 @@ module.exports = async (config = {}) => { if (['fabric', 'canvas'].includes(type)) assert(typeof layer.func === 'function', '"func" must be a function'); - if (['image', 'fabric', 'canvas', 'gl', 'radial-gradient', 'linear-gradient', 'fill-color'].includes(type)) return layer; + if (['image', 'image-overlay', 'fabric', 'canvas', 'gl', 'radial-gradient', 'linear-gradient', 'fill-color'].includes(type)) return layer; // TODO if random-background radial-gradient linear etc if (type === 'pause') return handleLayer({ ...restLayer, type: 'fill-color' }); diff --git a/sources/fabricFrameSource.js b/sources/fabricFrameSource.js index 48e2de5..8b8211e 100644 --- a/sources/fabricFrameSource.js +++ b/sources/fabricFrameSource.js @@ -7,6 +7,7 @@ const { createCanvas } = nodeCanvas; const { canvasToRgba } = require('./shared'); const { getRandomGradient, getRandomColors } = require('../colors'); const { easeOutExpo } = require('../transitions'); +const { getPositionProps } = require('../util'); // http://fabricjs.com/kitchensink @@ -56,10 +57,21 @@ async function createFabricFrameSource(func, { width, height, ...rest }) { }; } +const loadImage = async (path) => new Promise((resolve) => fabric.util.loadImage(fileUrl(path), resolve)); + +function getZoomParams({ progress, zoomDirection, zoomAmount }) { + let scaleFactor = 1; + if (zoomDirection === 'in') scaleFactor = (1 + zoomAmount * progress); + else if (zoomDirection === 'out') scaleFactor = (1 + zoomAmount * (1 - progress)); + return scaleFactor; +} + async function imageFrameSource({ verbose, params, width, height }) { - if (verbose) console.log('Loading', params.path); + const { path, zoomDirection = 'in', zoomAmount = 0.1 } = params; - const imgData = await new Promise((resolve) => fabric.util.loadImage(fileUrl(params.path), resolve)); + if (verbose) console.log('Loading', path); + + const imgData = await loadImage(path); const getImg = () => new fabric.Image(imgData, { originX: 'center', @@ -76,15 +88,10 @@ async function imageFrameSource({ verbose, params, width, height }) { if (blurredImg.height > blurredImg.width) blurredImg.scaleToWidth(width); else blurredImg.scaleToHeight(height); - async function onRender(progress, canvas) { - const { zoomDirection = 'in', zoomAmount = 0.1 } = params; - const img = getImg(); - let scaleFactor = 1; - if (zoomDirection === 'in') scaleFactor = (1 + progress * zoomAmount); - else if (zoomDirection === 'out') scaleFactor = (1 + zoomAmount * (1 - progress)); + const scaleFactor = getZoomParams({ progress, zoomDirection, zoomAmount }); if (img.height > img.width) img.scaleToHeight(height * scaleFactor); else img.scaleToWidth(width * scaleFactor); @@ -232,34 +239,40 @@ async function subtitleFrameSource({ width, height, params }) { return { onRender }; } -function getPositionProps({ position, width, height, objHeight }) { - let originY = 'center'; - let originX = 'center'; - let top = height / 2; - let left = width / 2; - - if (position === 'top') { - originY = 'top'; - top = height * objHeight; - } else if (position === 'bottom') { - originY = 'bottom'; - top = height; - } +async function imageOverlayFrameSource({ params, width, height }) { + const { path, position, width: relWidth, height: relHeight, zoomDirection, zoomAmount = 0.1 } = params; - if (position.x != null) { - originX = position.originX || 'left'; - left = width * position.x; - } - if (position.y != null) { - originY = position.originY || 'top'; - top = height * position.y; + const imgData = await loadImage(path); + + const { left, top, originX, originY } = getPositionProps({ position, width, height }); + + const img = new fabric.Image(imgData, { + originX, + originY, + left, + top, + }); + + async function onRender(progress, canvas) { + const scaleFactor = getZoomParams({ progress, zoomDirection, zoomAmount }); + + if (relWidth != null) { + img.scaleToWidth(relWidth * width * scaleFactor); + } else if (relHeight != null) { + img.scaleToHeight(relHeight * height * scaleFactor); + } else { + // Default to screen width + img.scaleToWidth(width * scaleFactor); + } + + canvas.add(img); } - return { originX, originY, top, left }; + return { onRender }; } async function titleFrameSource({ width, height, params }) { - const { text, textColor = '#ffffff', fontFamily = 'sans-serif', position = 'center' } = params; + const { text, textColor = '#ffffff', fontFamily = 'sans-serif', position = 'center', zoomDirection = 'in', zoomAmount = 0.2 } = params; async function onRender(progress, canvas) { // console.log('progress', progress); @@ -268,7 +281,7 @@ async function titleFrameSource({ width, height, params }) { const fontSize = Math.round(min * 0.1); - const scale = (1 + progress * 0.2).toFixed(4); + const scaleFactor = getZoomParams({ progress, zoomDirection, zoomAmount }); const textBox = new fabric.Textbox(text, { fill: textColor, @@ -281,15 +294,15 @@ async function titleFrameSource({ width, height, params }) { // We need the text as an image in order to scale it const textImage = await new Promise((r) => textBox.cloneAsImage(r)); - const { left, top, originX, originY } = getPositionProps({ position, width, height, objHeight: 0.05 }); + const { left, top, originX, originY } = getPositionProps({ position, width, height }); textImage.set({ originX, originY, left, top, - scaleX: scale, - scaleY: scale, + scaleX: scaleFactor, + scaleY: scaleFactor, }); canvas.add(textImage); } @@ -382,6 +395,7 @@ module.exports = { radialGradientFrameSource, linearGradientFrameSource, imageFrameSource, + imageOverlayFrameSource, createFabricCanvas, renderFabricCanvas, diff --git a/sources/frameSource.js b/sources/frameSource.js index 659e6ac..dd1dc96 100644 --- a/sources/frameSource.js +++ b/sources/frameSource.js @@ -1,7 +1,7 @@ const assert = require('assert'); const pMap = require('p-map'); -const { rgbaToFabricImage, customFabricFrameSource, createCustomCanvasFrameSource, titleFrameSource, subtitleFrameSource, imageFrameSource, linearGradientFrameSource, radialGradientFrameSource, fillColorFrameSource, createFabricFrameSource, newsTitleFrameSource, createFabricCanvas, renderFabricCanvas } = require('./fabricFrameSource'); +const { rgbaToFabricImage, customFabricFrameSource, createCustomCanvasFrameSource, titleFrameSource, subtitleFrameSource, imageFrameSource, imageOverlayFrameSource, linearGradientFrameSource, radialGradientFrameSource, fillColorFrameSource, createFabricFrameSource, newsTitleFrameSource, createFabricCanvas, renderFabricCanvas } = require('./fabricFrameSource'); const createVideoFrameSource = require('./videoFrameSource'); const { createGlFrameSource } = require('./glFrameSource'); @@ -20,6 +20,7 @@ async function createFrameSource({ clip, clipIndex, width, height, channels, ver canvas: createCustomCanvasFrameSource, fabric: async (opts) => createFabricFrameSource(customFabricFrameSource, opts), image: async (opts) => createFabricFrameSource(imageFrameSource, opts), + 'image-overlay': async (opts) => createFabricFrameSource(imageOverlayFrameSource, opts), title: async (opts) => createFabricFrameSource(titleFrameSource, opts), subtitle: async (opts) => createFabricFrameSource(subtitleFrameSource, opts), 'linear-gradient': async (opts) => createFabricFrameSource(linearGradientFrameSource, opts), diff --git a/util.js b/util.js index c4db287..5933143 100644 --- a/util.js +++ b/util.js @@ -62,6 +62,36 @@ function toArrayInteger(buffer) { const multipleOf2 = (x) => (x + (x % 2)); +function getPositionProps({ position, width, height }) { + let originY = 'center'; + let originX = 'center'; + let top = height / 2; + let left = width / 2; + + const margin = 0.05; + if (position === 'top') { + originY = 'top'; + top = height * margin; + } else if (position === 'bottom') { + originY = 'bottom'; + top = height * (1 - margin); + } else if (position === 'center') { + originY = 'center'; + top = height / 2; + } + + if (position && position.x != null) { + originX = position.originX || 'left'; + left = width * position.x; + } + if (position && position.y != null) { + originY = position.originY || 'top'; + top = height * position.y; + } + + return { originX, originY, top, left }; +} + module.exports = { parseFps, readVideoFileInfo, @@ -69,4 +99,5 @@ module.exports = { multipleOf2, toArrayInteger, readFileStreams, + getPositionProps, };