Browse Source

refactor out config parsing and implement renderSingleFrame

layer-manipulation
Mikael Finstad 6 years ago
parent
commit
fc2cb572fe
  1. 11
      examples/renderSingleFrame.js
  2. 275
      index.js
  3. 214
      parseConfig.js
  4. 1
      sources/fabric.js
  5. 15
      util.js

11
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);

275
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;

214
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;

1
sources/fabric.js

@ -121,4 +121,5 @@ module.exports = {
renderFabricCanvas,
rgbaToFabricImage,
fabricCanvasToFabricImage,
getNodeCanvasFromFabricCanvas,
};

15
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,
};
Loading…
Cancel
Save