diff --git a/examples/renderSingleFrame.js b/examples/renderSingleFrame.js new file mode 100644 index 0000000..2e531b4 --- /dev/null +++ b/examples/renderSingleFrame.js @@ -0,0 +1,11 @@ +const JSON5 = require('json5'); +const fs = require('fs-extra'); + +const { renderSingleFrame } = require('..'); + +(async () => { + await renderSingleFrame({ + time: 0, + clips: JSON5.parse(await fs.readFile('./videos.json5', 'utf-8')).clips, + }); +})().catch(console.error); \ No newline at end of file diff --git a/index.js b/index.js index a3f478b..3edc69c 100644 --- a/index.js +++ b/index.js @@ -1,27 +1,22 @@ const execa = require('execa'); const assert = require('assert'); -const pMap = require('p-map'); -const { basename, join, dirname } = require('path'); -const flatMap = require('lodash/flatMap'); +const { join, dirname } = require('path'); const JSON5 = require('json5'); const fs = require('fs-extra'); const { nanoid } = require('nanoid'); -const { parseFps, readVideoFileInfo, readAudioFileInfo, multipleOf2, isUrl } = require('./util'); -const { registerFont } = require('./sources/fabric'); +const { parseFps, multipleOf2 } = require('./util'); +const { createFabricCanvas, rgbaToFabricImage, getNodeCanvasFromFabricCanvas } = require('./sources/fabric'); const { createFrameSource } = require('./sources/frameSource'); -const { calcTransition } = require('./transitions'); - +const parseConfig = require('./parseConfig'); const GlTransitions = require('./glTransitions'); const Audio = require('./audio'); +const { assertFileValid, checkTransition } = require('./util'); -// Cache -const loadedFonts = []; +const channels = 4; -// See #16 -const checkTransition = (transition) => assert(transition == null || typeof transition === 'object', 'Transition must be an object'); -module.exports = async (config = {}) => { +const Editly = async (config = {}) => { const { // Testing options: enableFfmpegLog = false, @@ -34,7 +29,7 @@ module.exports = async (config = {}) => { width: requestedWidth, height: requestedHeight, fps: requestedFps, - defaults: defaultsIn = {}, + defaults = {}, audioFilePath: audioFilePathIn, loopAudio, keepSourceAudio, @@ -44,223 +39,21 @@ module.exports = async (config = {}) => { ffprobePath = 'ffprobe', } = config; - const assertFileValid = async (path) => { - if (isUrl(path)) { - assert(allowRemoteRequests, 'Remote requests are not allowed'); - return; - } - assert(await fs.exists(path), `File does not exist ${path}`); - }; - const isGif = outPath.toLowerCase().endsWith('.gif'); let audioFilePath; if (!isGif) audioFilePath = audioFilePathIn; - if (audioFilePath) await assertFileValid(audioFilePath); + if (audioFilePath) await assertFileValid(audioFilePath, allowRemoteRequests); - checkTransition(defaultsIn.transition); - - const defaults = { - duration: 4, - ...defaultsIn, - transition: defaultsIn.transition === null ? null : { - duration: 0.5, - name: 'random', - ...defaultsIn.transition, - }, - }; + checkTransition(defaults.transition); if (verbose) console.log(JSON5.stringify(config, null, 2)); assert(outPath, 'Please provide an output path'); assert(clipsIn.length > 0, 'Please provide at least 1 clip'); - async function handleLayer(layer) { - const { type, ...restLayer } = layer; - - // https://github.com/mifi/editly/issues/39 - if (['image', 'image-overlay'].includes(type)) { - await assertFileValid(restLayer.path); - } else if (type === 'gl') { - await assertFileValid(restLayer.fragmentPath); - } - - if (['fabric', 'canvas'].includes(type)) assert(typeof layer.func === 'function', '"func" must be a function'); - - 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' }); - - if (type === 'rainbow-colors') return handleLayer({ type: 'gl', fragmentPath: join(__dirname, 'shaders/rainbow-colors.frag') }); - - if (type === 'editly-banner') { - const { fontPath } = layer; - return [ - await handleLayer({ type: 'linear-gradient' }), - await handleLayer({ fontPath, type: 'title', text: 'Made with\nEDITLY\nmifi.no' }), - ]; - } - - // For convenience - if (type === 'title-background') { - const { text, textColor, background, fontFamily, fontPath } = layer; - const outLayers = []; - if (background) { - if (background.type === 'radial-gradient') outLayers.push(await handleLayer({ type: 'radial-gradient', colors: background.colors })); - else if (background.type === 'linear-gradient') outLayers.push(await handleLayer({ type: 'linear-gradient', colors: background.colors })); - else if (background.color) outLayers.push(await handleLayer({ type: 'fill-color', color: background.color })); - } else { - const backgroundTypes = ['radial-gradient', 'linear-gradient', 'fill-color']; - const randomType = backgroundTypes[Math.floor(Math.random() * backgroundTypes.length)]; - outLayers.push(await handleLayer({ type: randomType })); - } - outLayers.push(await handleLayer({ type: 'title', fontFamily, fontPath, text, textColor })); - return outLayers; - } - - if (['title', 'subtitle', 'news-title', 'slide-in-text'].includes(type)) { - assert(layer.text, 'Please specify a text'); - - let { fontFamily } = layer; - const { fontPath, ...rest } = layer; - if (fontPath) { - fontFamily = Buffer.from(basename(fontPath)).toString('base64'); - if (!loadedFonts.includes(fontFamily)) { - registerFont(fontPath, { family: fontFamily, weight: 'regular', style: 'normal' }); - loadedFonts.push(fontFamily); - } - } - return { ...rest, fontFamily }; - } - - throw new Error(`Invalid layer type ${type}`); - } - - const clips = await pMap(clipsIn, async (clip, clipIndex) => { - assert(typeof clip === 'object', '"clips" must contain objects with one or more layers'); - const { transition: userTransition, duration: userClipDuration, layers: layersIn } = clip; - - // Validation - let layers = layersIn; - if (!Array.isArray(layers)) layers = [layers]; // Allow single layer for convenience - assert(layers.every((layer) => layer != null && typeof layer === 'object'), '"clip.layers" must contain one or more objects'); - assert(layers.every((layer) => layer.type != null), 'All "layers" must have a type'); - - checkTransition(userTransition); - - const videoLayers = layers.filter((layer) => layer.type === 'video'); - - const userClipDurationOrDefault = userClipDuration || defaults.duration; - if (videoLayers.length === 0) assert(userClipDurationOrDefault, `Duration parameter is required for videoless clip ${clipIndex}`); - - const transition = calcTransition(defaults, userTransition, clipIndex === clipsIn.length - 1); - - let layersOut = flatMap(await pMap(layers, async (layerIn) => { - const globalLayerDefaults = defaults.layer || {}; - const thisLayerDefaults = (defaults.layerType || {})[layerIn.type]; - const layer = { ...globalLayerDefaults, ...thisLayerDefaults, ...layerIn }; - const { type, path } = layer; - - if (type === 'video') { - const { duration: fileDuration, width: widthIn, height: heightIn, framerateStr, rotation } = await readVideoFileInfo(ffprobePath, path); - let { cutFrom, cutTo } = layer; - if (!cutFrom) cutFrom = 0; - cutFrom = Math.max(cutFrom, 0); - cutFrom = Math.min(cutFrom, fileDuration); - - if (!cutTo) cutTo = fileDuration; - cutTo = Math.max(cutTo, cutFrom); - cutTo = Math.min(cutTo, fileDuration); - assert(cutFrom < cutTo, 'cutFrom must be lower than cutTo'); - - const inputDuration = cutTo - cutFrom; - - const isRotated = rotation === 90 || rotation === 270; - const width = isRotated ? heightIn : widthIn; - const height = isRotated ? widthIn : heightIn; - - // Compensate for transition duration - const audioCutTo = Math.max(cutFrom, cutTo - transition.duration); - - return { ...layer, cutFrom, cutTo, audioCutTo, inputDuration, width, height, framerateStr }; - } - - // Audio is handled later - if (type === 'audio') return layer; - - return handleLayer(layer); - }, { concurrency: 1 })); - - let clipDuration = userClipDurationOrDefault; - - const firstVideoLayer = layersOut.find((layer) => layer.type === 'video'); - if (firstVideoLayer && !userClipDuration) clipDuration = firstVideoLayer.inputDuration; - assert(clipDuration); - - // We need to map again, because for audio, we need to know the correct clipDuration - layersOut = await pMap(layersOut, async (layerIn) => { - const { type, path, visibleUntil, visibleFrom = 0 } = layerIn; - - // This feature allows the user to show another layer overlayed (or replacing) parts of the lower layers (visibleFrom - visibleUntil) - const visibleDuration = ((visibleUntil || clipDuration) - visibleFrom); - assert(visibleDuration > 0 && visibleDuration <= clipDuration, `Invalid visibleFrom ${visibleFrom} or visibleUntil ${visibleUntil} (${clipDuration})`); - // TODO Also need to handle video layers (framePtsFactor etc) - // TODO handle audio in case of visibleFrom/visibleTo - - const layer = { ...layerIn, visibleFrom, visibleDuration }; - - if (type === 'audio') { - const { duration: fileDuration } = await readAudioFileInfo(ffprobePath, path); - let { cutFrom, cutTo } = layer; - - // console.log({ cutFrom, cutTo, fileDuration, clipDuration }); - - if (!cutFrom) cutFrom = 0; - cutFrom = Math.max(cutFrom, 0); - cutFrom = Math.min(cutFrom, fileDuration); - - if (!cutTo) cutTo = cutFrom + clipDuration; - cutTo = Math.max(cutTo, cutFrom); - cutTo = Math.min(cutTo, fileDuration); - assert(cutFrom < cutTo, 'cutFrom must be lower than cutTo'); - - const inputDuration = cutTo - cutFrom; - - const framePtsFactor = clipDuration / inputDuration; - - // Compensate for transition duration - const audioCutTo = Math.max(cutFrom, cutTo - transition.duration); - - return { ...layer, cutFrom, cutTo, audioCutTo, framePtsFactor }; - } - - if (layer.type === 'video') { - const { inputDuration } = layer; - - let framePtsFactor; - - // If user explicitly specified duration for clip, it means that should be the output duration of the video - if (userClipDuration) { - // Later we will speed up or slow down video using this factor - framePtsFactor = userClipDuration / inputDuration; - } else { - framePtsFactor = 1; - } - - return { ...layer, framePtsFactor }; - } - - return layer; - }); - - return { - transition, - duration: clipDuration, - layers: layersOut, - }; - }, { concurrency: 1 }); + const clips = await parseConfig({ defaults, clips: clipsIn, allowRemoteRequests, ffprobePath }); const { editAudio } = Audio({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose }); @@ -359,8 +152,6 @@ module.exports = async (config = {}) => { return newAcc; }, 0); - const channels = 4; - const { runTransitionOnFrame } = GlTransitions({ width, height, channels }); function startFfmpegWriterProcess() { @@ -576,3 +367,47 @@ module.exports = async (config = {}) => { console.log('Done. Output file can be found at:'); console.log(outPath); }; + +// Pure function to get a frame at a certain time (excluding transitions) +async function renderSingleFrame({ + time = 0, + defaults, + width = 800, + height = 600, + clips: clipsIn, + + verbose, + logTimes, + enableFfmpegLog, + allowRemoteRequests, + ffprobePath = 'ffprobe', + ffmpegPath = 'ffmpeg', + outPath = `${Math.floor(Math.random() * 1e12)}.png`, +}) { + const clips = await parseConfig({ defaults, clips: clipsIn, allowRemoteRequests, ffprobePath }); + let totalDuration = 0; + const clip = clips.find((c) => { + if (totalDuration >= time) return true; + totalDuration += c.duration; + return false; + }); + assert(clip, 'No clip found at requested time'); + const clipIndex = clips.indexOf(clip); + const frameSource = await createFrameSource({ clip, clipIndex, width, height, channels, verbose, logTimes, ffmpegPath, ffprobePath, enableFfmpegLog, framerateStr: '1' }); + const rgba = await frameSource.readNextFrame({ time }); + + // TODO converting rgba to png can be done more easily? + const canvas = createFabricCanvas({ width, height }); + const fabricImage = await rgbaToFabricImage({ width, height, rgba }); + canvas.add(fabricImage); + canvas.renderAll(); + const internalCanvas = getNodeCanvasFromFabricCanvas(canvas); + await fs.writeFile(outPath, internalCanvas.toBuffer('image/png')); + canvas.clear(); + canvas.dispose(); + await frameSource.close(); +} + +Editly.renderSingleFrame = renderSingleFrame; + +module.exports = Editly; diff --git a/parseConfig.js b/parseConfig.js new file mode 100644 index 0000000..1e4459e --- /dev/null +++ b/parseConfig.js @@ -0,0 +1,214 @@ +const pMap = require('p-map'); +const { basename, join } = require('path'); +const flatMap = require('lodash/flatMap'); +const assert = require('assert'); + +const { readVideoFileInfo, readAudioFileInfo } = require('./util'); +const { registerFont } = require('./sources/fabric'); +const { calcTransition } = require('./transitions'); + +const { assertFileValid, checkTransition } = require('./util'); + +// Cache +const loadedFonts = []; + + +async function parseConfig({ defaults: defaultsIn = {}, clips, allowRemoteRequests, ffprobePath }) { + const defaults = { + duration: 4, + ...defaultsIn, + transition: defaultsIn.transition === null ? null : { + duration: 0.5, + name: 'random', + ...defaultsIn.transition, + }, + }; + + async function handleLayer(layer) { + const { type, ...restLayer } = layer; + + // https://github.com/mifi/editly/issues/39 + if (['image', 'image-overlay'].includes(type)) { + await assertFileValid(restLayer.path, allowRemoteRequests); + } else if (type === 'gl') { + await assertFileValid(restLayer.fragmentPath, allowRemoteRequests); + } + + if (['fabric', 'canvas'].includes(type)) assert(typeof layer.func === 'function', '"func" must be a function'); + + 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' }); + + if (type === 'rainbow-colors') return handleLayer({ type: 'gl', fragmentPath: join(__dirname, 'shaders/rainbow-colors.frag') }); + + if (type === 'editly-banner') { + const { fontPath } = layer; + return [ + await handleLayer({ type: 'linear-gradient' }), + await handleLayer({ fontPath, type: 'title', text: 'Made with\nEDITLY\nmifi.no' }), + ]; + } + + // For convenience + if (type === 'title-background') { + const { text, textColor, background, fontFamily, fontPath } = layer; + const outLayers = []; + if (background) { + if (background.type === 'radial-gradient') outLayers.push(await handleLayer({ type: 'radial-gradient', colors: background.colors })); + else if (background.type === 'linear-gradient') outLayers.push(await handleLayer({ type: 'linear-gradient', colors: background.colors })); + else if (background.color) outLayers.push(await handleLayer({ type: 'fill-color', color: background.color })); + } else { + const backgroundTypes = ['radial-gradient', 'linear-gradient', 'fill-color']; + const randomType = backgroundTypes[Math.floor(Math.random() * backgroundTypes.length)]; + outLayers.push(await handleLayer({ type: randomType })); + } + outLayers.push(await handleLayer({ type: 'title', fontFamily, fontPath, text, textColor })); + return outLayers; + } + + if (['title', 'subtitle', 'news-title', 'slide-in-text'].includes(type)) { + assert(layer.text, 'Please specify a text'); + + let { fontFamily } = layer; + const { fontPath, ...rest } = layer; + if (fontPath) { + fontFamily = Buffer.from(basename(fontPath)).toString('base64'); + if (!loadedFonts.includes(fontFamily)) { + registerFont(fontPath, { family: fontFamily, weight: 'regular', style: 'normal' }); + loadedFonts.push(fontFamily); + } + } + return { ...rest, fontFamily }; + } + + throw new Error(`Invalid layer type ${type}`); + } + + return pMap(clips, async (clip, clipIndex) => { + assert(typeof clip === 'object', '"clips" must contain objects with one or more layers'); + const { transition: userTransition, duration: userClipDuration, layers: layersIn } = clip; + + // Validation + let layers = layersIn; + if (!Array.isArray(layers)) layers = [layers]; // Allow single layer for convenience + assert(layers.every((layer) => layer != null && typeof layer === 'object'), '"clip.layers" must contain one or more objects'); + assert(layers.every((layer) => layer.type != null), 'All "layers" must have a type'); + + checkTransition(userTransition); + + const videoLayers = layers.filter((layer) => layer.type === 'video'); + + const userClipDurationOrDefault = userClipDuration || defaults.duration; + if (videoLayers.length === 0) assert(userClipDurationOrDefault, `Duration parameter is required for videoless clip ${clipIndex}`); + + const transition = calcTransition(defaults, userTransition, clipIndex === clips.length - 1); + + let layersOut = flatMap(await pMap(layers, async (layerIn) => { + const globalLayerDefaults = defaults.layer || {}; + const thisLayerDefaults = (defaults.layerType || {})[layerIn.type]; + const layer = { ...globalLayerDefaults, ...thisLayerDefaults, ...layerIn }; + const { type, path } = layer; + + if (type === 'video') { + const { duration: fileDuration, width: widthIn, height: heightIn, framerateStr, rotation } = await readVideoFileInfo(ffprobePath, path); + let { cutFrom, cutTo } = layer; + if (!cutFrom) cutFrom = 0; + cutFrom = Math.max(cutFrom, 0); + cutFrom = Math.min(cutFrom, fileDuration); + + if (!cutTo) cutTo = fileDuration; + cutTo = Math.max(cutTo, cutFrom); + cutTo = Math.min(cutTo, fileDuration); + assert(cutFrom < cutTo, 'cutFrom must be lower than cutTo'); + + const inputDuration = cutTo - cutFrom; + + const isRotated = rotation === 90 || rotation === 270; + const width = isRotated ? heightIn : widthIn; + const height = isRotated ? widthIn : heightIn; + + // Compensate for transition duration + const audioCutTo = Math.max(cutFrom, cutTo - transition.duration); + + return { ...layer, cutFrom, cutTo, audioCutTo, inputDuration, width, height, framerateStr }; + } + + // Audio is handled later + if (type === 'audio') return layer; + + return handleLayer(layer); + }, { concurrency: 1 })); + + let clipDuration = userClipDurationOrDefault; + + const firstVideoLayer = layersOut.find((layer) => layer.type === 'video'); + if (firstVideoLayer && !userClipDuration) clipDuration = firstVideoLayer.inputDuration; + assert(clipDuration); + + // We need to map again, because for audio, we need to know the correct clipDuration + layersOut = await pMap(layersOut, async (layerIn) => { + const { type, path, visibleUntil, visibleFrom = 0 } = layerIn; + + // This feature allows the user to show another layer overlayed (or replacing) parts of the lower layers (visibleFrom - visibleUntil) + const visibleDuration = ((visibleUntil || clipDuration) - visibleFrom); + assert(visibleDuration > 0 && visibleDuration <= clipDuration, `Invalid visibleFrom ${visibleFrom} or visibleUntil ${visibleUntil} (${clipDuration})`); + // TODO Also need to handle video layers (framePtsFactor etc) + // TODO handle audio in case of visibleFrom/visibleTo + + const layer = { ...layerIn, visibleFrom, visibleDuration }; + + if (type === 'audio') { + const { duration: fileDuration } = await readAudioFileInfo(ffprobePath, path); + let { cutFrom, cutTo } = layer; + + // console.log({ cutFrom, cutTo, fileDuration, clipDuration }); + + if (!cutFrom) cutFrom = 0; + cutFrom = Math.max(cutFrom, 0); + cutFrom = Math.min(cutFrom, fileDuration); + + if (!cutTo) cutTo = cutFrom + clipDuration; + cutTo = Math.max(cutTo, cutFrom); + cutTo = Math.min(cutTo, fileDuration); + assert(cutFrom < cutTo, 'cutFrom must be lower than cutTo'); + + const inputDuration = cutTo - cutFrom; + + const framePtsFactor = clipDuration / inputDuration; + + // Compensate for transition duration + const audioCutTo = Math.max(cutFrom, cutTo - transition.duration); + + return { ...layer, cutFrom, cutTo, audioCutTo, framePtsFactor }; + } + + if (layer.type === 'video') { + const { inputDuration } = layer; + + let framePtsFactor; + + // If user explicitly specified duration for clip, it means that should be the output duration of the video + if (userClipDuration) { + // Later we will speed up or slow down video using this factor + framePtsFactor = userClipDuration / inputDuration; + } else { + framePtsFactor = 1; + } + + return { ...layer, framePtsFactor }; + } + + return layer; + }); + + return { + transition, + duration: clipDuration, + layers: layersOut, + }; + }, { concurrency: 1 }); +} + +module.exports = parseConfig; diff --git a/sources/fabric.js b/sources/fabric.js index 1e98dde..f43a27a 100644 --- a/sources/fabric.js +++ b/sources/fabric.js @@ -121,4 +121,5 @@ module.exports = { renderFabricCanvas, rgbaToFabricImage, fabricCanvasToFabricImage, + getNodeCanvasFromFabricCanvas, }; diff --git a/util.js b/util.js index 229a775..3e84445 100644 --- a/util.js +++ b/util.js @@ -1,7 +1,7 @@ const execa = require('execa'); const assert = require('assert'); const sortBy = require('lodash/sortBy'); - +const fs = require('fs-extra'); function parseFps(fps) { const match = typeof fps === 'string' && fps.match(/^([0-9]+)\/([0-9]+)$/); @@ -153,6 +153,17 @@ function getFrameByKeyFrames(keyframes, progress) { const isUrl = (path) => /^https?:\/\//.test(path); +const assertFileValid = async (path, allowRemoteRequests) => { + if (isUrl(path)) { + assert(allowRemoteRequests, 'Remote requests are not allowed'); + return; + } + assert(await fs.exists(path), `File does not exist ${path}`); +}; + +// See #16 +const checkTransition = (transition) => assert(transition == null || typeof transition === 'object', 'Transition must be an object'); + module.exports = { parseFps, @@ -164,4 +175,6 @@ module.exports = { getPositionProps, getFrameByKeyFrames, isUrl, + assertFileValid, + checkTransition, };