25 changed files with 1769 additions and 0 deletions
-
15.eslintrc
-
107cli.js
-
199colors.js
-
36examples/commonFeatures.json5
-
32examples/customCanvas.js
-
35examples/customFabric.js
-
16examples/gl.json5
-
7examples/image.json5
-
36examples/losslesscut.json5
-
13examples/resizeHorizontal.json5
-
14examples/resizeVertical.json5
-
15examples/speedTest.json5
-
13examples/transitionEasing.json5
-
7examples/transparentGradient.json5
-
71glTransitions.js
-
388index.js
-
42package.json
-
16shaders/rainbow-colors.frag
-
331sources/fabricFrameSource.js
-
61sources/frameSource.js
-
65sources/glFrameSource.js
-
21sources/shared.js
-
124sources/videoFrameSource.js
-
71transitions.js
-
34util.js
@ -0,0 +1,15 @@ |
|||||
|
{ |
||||
|
"extends": "airbnb-base", |
||||
|
"env": { |
||||
|
"node": true |
||||
|
}, |
||||
|
"parserOptions": { |
||||
|
"sourceType": "script" |
||||
|
}, |
||||
|
"rules": { |
||||
|
"max-len": 0, |
||||
|
"no-console": 0, |
||||
|
"object-curly-newline": 0, |
||||
|
"no-await-in-loop": 0, |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,107 @@ |
|||||
|
#!/usr/bin/env node
|
||||
|
const meow = require('meow'); |
||||
|
const fs = require('fs'); |
||||
|
const FileType = require('file-type'); |
||||
|
const pMap = require('p-map'); |
||||
|
const JSON5 = require('json5'); |
||||
|
const assert = require('assert'); |
||||
|
|
||||
|
const editly = require('.'); |
||||
|
|
||||
|
|
||||
|
const cli = meow(`
|
||||
|
Usage |
||||
|
$ editly CLIP1 [CLIP2 [CLIP3 ...]] |
||||
|
where each CLIP can be one of the following: |
||||
|
- A path to a video file |
||||
|
- A path to an image |
||||
|
- A quoted text to show in a title screen, prefixed by "title:" |
||||
|
|
||||
|
Or alternatively: |
||||
|
$ editly --json JSON_PATH |
||||
|
where JSON_PATH is the path to an edit spec JSON file, can be a normal JSON or JSON5 |
||||
|
|
||||
|
Options |
||||
|
--out Out video path (defaults to ./editly-out.mp4) - can also be a .gif |
||||
|
--json Use JSON config, all other options will be ignored |
||||
|
--transition-name Name of default transition to use |
||||
|
--transition-duration Default transition duration in milliseconds |
||||
|
--width Width which all videos will be converted to |
||||
|
--height Height which all videos will be converted to |
||||
|
--fps FPS which all videos will be converted to |
||||
|
--font-path Set default font to a .ttf |
||||
|
--audio-file-path Add an audio track |
||||
|
|
||||
|
--fast, -f Fast mode (low resolution and FPS, useful for getting a quick preview) |
||||
|
--verbose |
||||
|
|
||||
|
Examples |
||||
|
$ editly title:'My video' clip1.mov clip2.mov title:'My slideshow' img1.jpg img2.jpg title:'THE END' --audio-file-path /path/to/music.mp3 --font-path /path/to/my-favorite-font.ttf |
||||
|
$ editly --json my-editly.json --out output.gif |
||||
|
`, {
|
||||
|
flags: { |
||||
|
fast: { type: 'boolean', alias: 'f' }, |
||||
|
transitionDuration: { type: 'number' }, |
||||
|
width: { type: 'number' }, |
||||
|
height: { type: 'number' }, |
||||
|
fps: { type: 'number' }, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
(async () => { |
||||
|
let params = { |
||||
|
defaults: {}, |
||||
|
}; |
||||
|
if (cli.flags.json) { |
||||
|
params = JSON5.parse(fs.readFileSync(cli.flags.json, 'utf-8')); |
||||
|
} else { |
||||
|
const clipsIn = cli.input; |
||||
|
if (clipsIn.length < 1) cli.showHelp(); |
||||
|
|
||||
|
const clips = await pMap(clipsIn, async (clip) => { |
||||
|
const match = clip.match(/^title:(.+)$/); |
||||
|
if (match) return { type: 'title-background', text: match[1] }; |
||||
|
|
||||
|
const { mime } = await FileType.fromFile(clip); |
||||
|
|
||||
|
if (mime.startsWith('video')) return { type: 'video', path: clip }; |
||||
|
if (mime.startsWith('image')) return { type: 'image', path: clip }; |
||||
|
|
||||
|
throw new Error(`Unrecognized clip or file type "${clip}"`); |
||||
|
}, { concurrency: 1 }); |
||||
|
|
||||
|
assert(clips.length > 0, 'No clips specified'); |
||||
|
|
||||
|
params.clips = clips.map((clip) => ({ layers: [clip] })); |
||||
|
} |
||||
|
|
||||
|
const { verbose, transitionName, transitionDuration, width, height, fps, audioFilePath, fontPath, fast, out: outPath } = cli.flags; |
||||
|
|
||||
|
if (transitionName || transitionDuration) { |
||||
|
params.defaults.transition = { |
||||
|
name: transitionName, |
||||
|
duration: transitionDuration, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
if (fontPath) { |
||||
|
params.defaults.layer = { |
||||
|
fontPath, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
if (outPath) params.outPath = outPath; |
||||
|
if (audioFilePath) params.audioFilePath = audioFilePath; |
||||
|
if (width) params.width = width; |
||||
|
if (height) params.height = height; |
||||
|
if (fps) params.fps = fps; |
||||
|
|
||||
|
if (fast) params.fast = fast; |
||||
|
if (verbose) params.verbose = verbose; |
||||
|
|
||||
|
if (params.verbose) console.log(JSON5.stringify(params, null, 2)); |
||||
|
|
||||
|
if (!params.outPath) params.outPath = './editly-out.mp4'; |
||||
|
|
||||
|
await editly(params); |
||||
|
})().catch(console.error); |
||||
@ -0,0 +1,199 @@ |
|||||
|
// TODO make separate npm module
|
||||
|
|
||||
|
// https://stackoverflow.com/a/4382138/6519037
|
||||
|
const allColors = [ |
||||
|
'hsl(42, 100%, 50%)', |
||||
|
'hsl(310, 34%, 37%)', |
||||
|
'hsl(24, 100%, 50%)', |
||||
|
'hsl(211, 38%, 74%)', |
||||
|
'hsl(350, 100%, 37%)', |
||||
|
'hsl(35, 52%, 59%)', |
||||
|
'hsl(22, 11%, 45%)', |
||||
|
'hsl(145, 100%, 24%)', |
||||
|
'hsl(348, 87%, 71%)', |
||||
|
'hsl(203, 100%, 27%)', |
||||
|
'hsl(11, 100%, 68%)', |
||||
|
'hsl(265, 37%, 34%)', |
||||
|
'hsl(33, 100%, 50%)', |
||||
|
'hsl(342, 63%, 42%)', |
||||
|
'hsl(49, 100%, 47%)', |
||||
|
'hsl(5, 81%, 27%)', |
||||
|
'hsl(68, 100%, 33%)', |
||||
|
'hsl(26, 61%, 21%)', |
||||
|
'hsl(10, 88%, 51%)', |
||||
|
'hsl(84, 33%, 12%)', |
||||
|
]; |
||||
|
|
||||
|
// https://digitalsynopsis.com/design/beautiful-color-ui-gradients-backgrounds/
|
||||
|
const gradientColors = [ |
||||
|
[ |
||||
|
'#ff9aac', |
||||
|
'#ffa875', |
||||
|
], |
||||
|
[ |
||||
|
'#cc2b5e', |
||||
|
'#753a88', |
||||
|
], |
||||
|
[ |
||||
|
'#42275a', |
||||
|
'#734b6d', |
||||
|
], |
||||
|
[ |
||||
|
'#bdc3c7', |
||||
|
'#2c3e50', |
||||
|
], |
||||
|
[ |
||||
|
'#de6262', |
||||
|
'#ffb88c', |
||||
|
], |
||||
|
[ |
||||
|
'#eb3349', |
||||
|
'#f45c43', |
||||
|
], |
||||
|
[ |
||||
|
'#dd5e89', |
||||
|
'#f7bb97', |
||||
|
], |
||||
|
[ |
||||
|
'#56ab2f', |
||||
|
'#a8e063', |
||||
|
], |
||||
|
[ |
||||
|
'#614385', |
||||
|
'#516395', |
||||
|
], |
||||
|
[ |
||||
|
'#eecda3', |
||||
|
'#ef629f', |
||||
|
], |
||||
|
[ |
||||
|
'#eacda3', |
||||
|
'#d6ae7b', |
||||
|
], |
||||
|
[ |
||||
|
'#02aab0', |
||||
|
'#00cdac', |
||||
|
], |
||||
|
[ |
||||
|
'#d66d75', |
||||
|
'#e29587', |
||||
|
], |
||||
|
[ |
||||
|
'#000428', |
||||
|
'#004e92', |
||||
|
], |
||||
|
[ |
||||
|
'#ddd6f3', |
||||
|
'#faaca8', |
||||
|
], |
||||
|
[ |
||||
|
'#7b4397', |
||||
|
'#dc2430', |
||||
|
], |
||||
|
[ |
||||
|
'#43cea2', |
||||
|
'#185a9d', |
||||
|
], |
||||
|
[ |
||||
|
'#ba5370', |
||||
|
'#f4e2d8', |
||||
|
], |
||||
|
[ |
||||
|
'#ff512f', |
||||
|
'#dd2476', |
||||
|
], |
||||
|
[ |
||||
|
'#4568dc', |
||||
|
'#b06ab3', |
||||
|
], |
||||
|
[ |
||||
|
'#ec6f66', |
||||
|
'#f3a183', |
||||
|
], |
||||
|
[ |
||||
|
'#ffd89b', |
||||
|
'#19547b', |
||||
|
], |
||||
|
[ |
||||
|
'#3a1c71', |
||||
|
'#d76d77', |
||||
|
], |
||||
|
[ |
||||
|
'#4ca1af', |
||||
|
'#c4e0e5', |
||||
|
], |
||||
|
[ |
||||
|
'#ff5f6d', |
||||
|
'#ffc371', |
||||
|
], |
||||
|
[ |
||||
|
'#36d1dc', |
||||
|
'#5b86e5', |
||||
|
], |
||||
|
[ |
||||
|
'#c33764', |
||||
|
'#1d2671', |
||||
|
], |
||||
|
[ |
||||
|
'#141e30', |
||||
|
'#243b55', |
||||
|
], |
||||
|
[ |
||||
|
'#ff7e5f', |
||||
|
'#feb47b', |
||||
|
], |
||||
|
[ |
||||
|
'#ed4264', |
||||
|
'#ffedbc', |
||||
|
], |
||||
|
[ |
||||
|
'#2b5876', |
||||
|
'#4e4376', |
||||
|
], |
||||
|
[ |
||||
|
'#ff9966', |
||||
|
'#ff5e62', |
||||
|
], |
||||
|
[ |
||||
|
'#aa076b', |
||||
|
'#61045f', |
||||
|
], |
||||
|
]; |
||||
|
|
||||
|
/* const lightGradients = [ |
||||
|
[ |
||||
|
'#ee9ca7', |
||||
|
'#ffdde1', |
||||
|
], |
||||
|
[ |
||||
|
'#2193b0', |
||||
|
'#6dd5ed', |
||||
|
], |
||||
|
]; */ |
||||
|
|
||||
|
function getRandomColor(colors = allColors) { |
||||
|
const index = Math.floor(Math.random() * colors.length); |
||||
|
const remainingColors = [...colors]; |
||||
|
remainingColors.splice(index, 1); |
||||
|
return { remainingColors, color: colors[index] || allColors[0] }; |
||||
|
} |
||||
|
|
||||
|
function getRandomColors(num) { |
||||
|
let colors = allColors; |
||||
|
const out = []; |
||||
|
for (let i = 0; i < Math.min(num, allColors.length); i += 1) { |
||||
|
const { remainingColors, color } = getRandomColor(colors); |
||||
|
out.push(color); |
||||
|
colors = remainingColors; |
||||
|
} |
||||
|
return out; |
||||
|
} |
||||
|
|
||||
|
function getRandomGradient() { |
||||
|
return gradientColors[Math.floor(Math.random() * gradientColors.length)]; |
||||
|
} |
||||
|
|
||||
|
module.exports = { |
||||
|
getRandomColors, |
||||
|
getRandomGradient, |
||||
|
}; |
||||
@ -0,0 +1,36 @@ |
|||||
|
{ |
||||
|
// fast: true, |
||||
|
// width: 2166, height: 1650, fps: 30, |
||||
|
outPath: './out.mp4', |
||||
|
// outPath: './out.gif', |
||||
|
// verbose: true, |
||||
|
// enableFfmpegLog: true, |
||||
|
audioFilePath: './High [NCS Release] - JPB (No Copyright Music)-R8ZRCXy5vhA.m4a', |
||||
|
defaults: { |
||||
|
transition: { name: 'random' }, |
||||
|
layer: { fontPath: './Patua_One/PatuaOne-Regular.ttf' }, |
||||
|
}, |
||||
|
clips: [ |
||||
|
{ duration: 3, transition: { name: 'directional-left' }, layers: [{ type: 'title-background', text: 'EDITLY\nVideo editing framework', background: { type: 'linear-gradient', colors: ['#02aab0', '#00cdac'] } }] }, |
||||
|
{ duration: 4, transition: { name: 'dreamyzoom' }, layers: [{ type: 'title-background', text: 'Multi-line text with animated linear or radial gradients', background: { type: 'radial-gradient' } }] }, |
||||
|
{ duration: 3, transition: { name: 'directional-right' }, layers: [{ type: 'rainbow-colors' }, { type: 'title', text: 'Colorful backgrounds' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'pause' }, { type: 'title', text: 'and separators' }] }, |
||||
|
|
||||
|
{ duration: 3, transition: { name: 'fadegrayscale' }, layers: [{ type: 'title-background', text: 'Image slideshows with Ken Burns effect', background: { type: 'linear-gradient' } }] }, |
||||
|
{ duration: 2.5, transition: { name: 'directionalWarp' }, layers: [{ type: 'image', path: './vertical.jpg', zoomDirection: 'out' }] }, |
||||
|
{ duration: 3, transition: { name: 'dreamyzoom' }, layers: [{ type: 'image', path: './img1.jpg', duration: 2.5, zoomDirection: 'in' }, { type: 'subtitle', text: 'Indonesia has many spectacular locations. Here is the volcano Kelimutu, which has three lakes in its core, some days with three different colors!' }, { type: 'title', position: 'top', text: 'With text' }] }, |
||||
|
{ duration: 3, transition: { name: 'colorphase' }, layers: [{ type: 'image', path: './img2.jpg', zoomDirection: 'out' }, { type: 'subtitle', text: 'Komodo national park is the only home of the endangered Komodo dragons' }] }, |
||||
|
{ duration: 2.5, transition: { name: 'simplezoom' }, layers: [{ type: 'image', path: './img3.jpg', zoomDirection: 'in' }] }, |
||||
|
|
||||
|
{ duration: 1.5, transition: { name: 'crosszoom', duration: 0.3 }, layers: [{ type: 'video', path: '/Users/mifi/Desktop/photos/drone koh lipe/DJI_0402.MOV', cutTo: 58 }, { type: 'title', text: 'Videos' }] }, |
||||
|
{ duration: 3, transition: { name: 'fade' }, layers: [{ type: 'video', path: '/Users/mifi/Desktop/photos/drone koh lipe/DJI_0402.MOV', cutFrom: 58 }] }, |
||||
|
{ transition: { name: 'fade' }, layers: [{ type: 'video', path: '/Users/mifi/Desktop/photos/drone koh lipe/DJI_0403.MOV', cutTo: 2.5 }] }, |
||||
|
{ duration: 1.5, layers: [{ type: 'video', path: '/Users/mifi/Desktop/photos/drone koh lipe/DJI_0401.MOV', cutFrom: 3, cutTo: 30 }] }, |
||||
|
|
||||
|
{ duration: 3, transition: { name: 'crosszoom' }, layers: [{ type: 'gl', fragmentPath: './shaders/3l23Rh.frag' }, { type: 'title', text: 'OpenGL\nshaders' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'gl', fragmentPath: './shaders/MdXyzX.frag' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'gl', fragmentPath: './shaders/30daysofshade_010.frag' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'gl', fragmentPath: './shaders/wd2yDm.frag', speed: 5 }] }, |
||||
|
{ duration: 3, layers: [{ type: 'editly-banner' }] }, |
||||
|
], |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
const editly = require('..'); |
||||
|
|
||||
|
async function func({ width, height, canvas }) { |
||||
|
async function onRender(progress) { |
||||
|
const context = canvas.getContext('2d'); |
||||
|
const centerX = canvas.width / 2; |
||||
|
const centerY = canvas.height / 2; |
||||
|
const radius = 40 * (1 + progress * 0.5); |
||||
|
|
||||
|
context.beginPath(); |
||||
|
context.arc(centerX, centerY, radius, 0, 2 * Math.PI, false); |
||||
|
context.fillStyle = 'hsl(350, 100%, 37%)'; |
||||
|
context.fill(); |
||||
|
context.lineWidth = 5; |
||||
|
context.strokeStyle = '#ffffff'; |
||||
|
context.stroke(); |
||||
|
} |
||||
|
|
||||
|
function onClose() { |
||||
|
// Cleanup if you initialized anything
|
||||
|
} |
||||
|
|
||||
|
return { onRender, onClose }; |
||||
|
} |
||||
|
|
||||
|
editly({ |
||||
|
fast: true, |
||||
|
outPath: './canvas.mp4', |
||||
|
clips: [ |
||||
|
{ duration: 2, layers: [{ type: 'canvas', func }] }, |
||||
|
], |
||||
|
}).catch(console.error); |
||||
@ -0,0 +1,35 @@ |
|||||
|
const editly = require('..'); |
||||
|
|
||||
|
/* eslint-disable spaced-comment,no-param-reassign */ |
||||
|
|
||||
|
async function func({ width, height, fabric, canvas }) { |
||||
|
async function onRender(progress) { |
||||
|
canvas.backgroundColor = 'hsl(33, 100%, 50%)'; |
||||
|
|
||||
|
const text = new fabric.Text(`PROGRESS\n${Math.floor(progress * 100)}%`, { |
||||
|
originX: 'center', |
||||
|
originY: 'center', |
||||
|
left: width / 2, |
||||
|
top: (height / 2) * (1 + (progress * 0.1 - 0.05)), |
||||
|
fontSize: 20, |
||||
|
textAlign: 'center', |
||||
|
fill: 'white', |
||||
|
}); |
||||
|
|
||||
|
canvas.add(text); |
||||
|
} |
||||
|
|
||||
|
function onClose() { |
||||
|
// Cleanup if you initialized anything
|
||||
|
} |
||||
|
|
||||
|
return { onRender, onClose }; |
||||
|
} |
||||
|
|
||||
|
editly({ |
||||
|
fast: true, |
||||
|
outPath: './fabric.mp4', |
||||
|
clips: [ |
||||
|
{ duration: 2, layers: [{ type: 'fabric', func }] }, |
||||
|
], |
||||
|
}).catch(console.error); |
||||
@ -0,0 +1,16 @@ |
|||||
|
{ |
||||
|
fast: true, |
||||
|
// verbose: true, |
||||
|
outPath: './gl.mp4', |
||||
|
defaults: { |
||||
|
transition: { name: 'random' }, |
||||
|
layer: { fontPath: './Patua_One/PatuaOne-Regular.ttf' }, |
||||
|
}, |
||||
|
clips: [ |
||||
|
{ transition: null, duration: 3, layers: [{ type: 'gl', fragmentPath: './shaders/3l23Rh.frag' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'gl', fragmentPath: './shaders/MdXyzX.frag' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'gl', fragmentPath: './shaders/30daysofshade_010.frag', speed: 1 }] }, |
||||
|
{ duration: 3, layers: [{ type: 'gl', fragmentPath: './shaders/rainbow-background.frag' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'gl', fragmentPath: './shaders/wd2yDm.frag', speed: 5 }] }, |
||||
|
], |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
{ |
||||
|
fast: true, |
||||
|
outPath: './image.mp4', |
||||
|
clips: [ |
||||
|
{ layers: [{ type: 'image', path: './vertical.jpg', zoomDirection: 'out' }] }, |
||||
|
], |
||||
|
} |
||||
@ -0,0 +1,36 @@ |
|||||
|
{ |
||||
|
// fast: false, |
||||
|
outPath: './losslesscut.mp4', |
||||
|
// verbose: true, |
||||
|
// enableFfmpegLog: true, |
||||
|
fps: 30, |
||||
|
audioFilePath: './Believe - Roa [Vlog No Copyright Music]-qldyHxWPFUY.m4a', |
||||
|
defaults: { |
||||
|
transition: { name: 'crossZoom', duration: 1 }, |
||||
|
layer: { fontPath: './Patua_One/PatuaOne-Regular.ttf' }, |
||||
|
}, |
||||
|
clips: [ |
||||
|
{ duration: 3, layers: [{ type: 'title-background', text: 'LosslessCut', background: { type: 'linear-gradient' } }] }, |
||||
|
{ layers: [{ type: 'video', path: '/Users/mifi/Desktop/losslesscut-usage/intro.mov' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'title-background', text: 'Capture full resolution screenshots', background: { type: 'radial-gradient' } }] }, |
||||
|
{ layers: [{ type: 'video', path: '/Users/mifi/Desktop/losslesscut-usage/capture screenshots.mov' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'title-background', text: 'Extract tracks as individual files', background: { type: 'radial-gradient' } }] }, |
||||
|
{ layers: [{ type: 'video', path: '/Users/mifi/Desktop/losslesscut-usage/extract tracks as individual files.mov' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'title-background', text: 'Keyframes and zoom', background: { type: 'radial-gradient' } }] }, |
||||
|
{ layers: [{ type: 'video', path: '/Users/mifi/Desktop/losslesscut-usage/keyframes and zoom.mov' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'title-background', text: 'Label segments', background: { type: 'radial-gradient' } }] }, |
||||
|
{ layers: [{ type: 'video', path: '/Users/mifi/Desktop/losslesscut-usage/label segments.mov' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'title-background', text: 'Lossless rotation', background: { type: 'radial-gradient' } }] }, |
||||
|
{ layers: [{ type: 'video', path: '/Users/mifi/Desktop/losslesscut-usage/lossless rotation.mov' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'title-background', text: 'Thumbnails', background: { type: 'radial-gradient' } }] }, |
||||
|
{ layers: [{ type: 'video', path: '/Users/mifi/Desktop/losslesscut-usage/thumbnails.mov' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'title-background', text: 'Audio waveforms', background: { type: 'radial-gradient' } }] }, |
||||
|
{ layers: [{ type: 'video', path: '/Users/mifi/Desktop/losslesscut-usage/audio waveform.mov' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'title-background', text: 'Track information', background: { type: 'radial-gradient' } }] }, |
||||
|
{ layers: [{ type: 'video', path: '/Users/mifi/Desktop/losslesscut-usage/track information.mov' }] }, |
||||
|
{ duration: 3, layers: [{ type: 'title-background', text: 'Tracks editor and audio swap', background: { type: 'radial-gradient' } }] }, |
||||
|
{ layers: [{ type: 'video', path: '/Users/mifi/Desktop/losslesscut-usage/tracks editor and replace audio.mov' }] }, |
||||
|
{ duration: 4, layers: [{ type: 'title-background', text: 'Get it from\nMac App Store\nWindows Store', background: { type: 'color', color: 'black' } }] }, |
||||
|
{ duration: 2, layers: [{ type: 'editly-banner' }] }, |
||||
|
], |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
{ |
||||
|
fast: true, |
||||
|
outPath: './resizeHorizontal.mp4', |
||||
|
defaults: { |
||||
|
transition: { duration: 0 }, |
||||
|
layer: { fontPath: './Patua_One/PatuaOne-Regular.ttf', backgroundColor: 'white' }, |
||||
|
}, |
||||
|
clips: [ |
||||
|
{ layers: [{ type: 'video', path: './IMG_4605.MOV', cutFrom: 0.4, cutTo: 2 }] }, |
||||
|
{ layers: [{ type: 'video', path: './IMG_4605.MOV', cutFrom: 0.4, cutTo: 2, resizeMode: 'contain' }] }, |
||||
|
{ layers: [{ type: 'video', path: './IMG_4605.MOV', cutFrom: 0.4, cutTo: 2, resizeMode: 'stretch' }] }, |
||||
|
], |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
{ |
||||
|
width: 240, height: 320, fps: 15, |
||||
|
outPath: './resizeVertical.mp4', |
||||
|
defaults: { |
||||
|
transition: { duration: 0 }, |
||||
|
layer: { fontPath: './Patua_One/PatuaOne-Regular.ttf' }, |
||||
|
}, |
||||
|
clips: [ |
||||
|
{ duration: 2, layers: [{ type: 'title-background', text: 'Editly can handle all formats and sizes with different fits', background: { type: 'radial-gradient' } }] }, |
||||
|
{ layers: [{ type: 'video', path: './IMG_4605.MOV', cutFrom: 0, cutTo: 2, resizeMode: 'contain' }, { type: 'title-background', text: 'Contain' }] }, |
||||
|
{ layers: [{ type: 'video', path: './IMG_4605.MOV', cutFrom: 0, cutTo: 2, resizeMode: 'stretch' }, { type: 'title-background', text: 'Stretch' }] }, |
||||
|
{ layers: [{ type: 'video', path: './IMG_4605.MOV', cutFrom: 0, cutTo: 2 }, { type: 'title-background', text: 'Cover' }] }, |
||||
|
], |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
{ |
||||
|
fast: true, |
||||
|
// verbose: true, |
||||
|
outPath: './speedTest.mp4', |
||||
|
defaults: { |
||||
|
transition: null, |
||||
|
layer: { fontPath: './Patua_One/PatuaOne-Regular.ttf' }, |
||||
|
}, |
||||
|
clips: [ |
||||
|
{ duration: 2, layers: [{ type: 'title-background', text: 'Speed up or slow down video', background: { type: 'radial-gradient' } }] }, |
||||
|
{ duration: 2, layers: [{ type: 'video', path: './IMG_4605.MOV', cutFrom: 0, cutTo: 2 }, { type: 'title-background', text: 'Same speed' }] }, |
||||
|
{ duration: 1, layers: [{ type: 'video', path: './IMG_4605.MOV', cutFrom: 0, cutTo: 4 }, { type: 'title-background', text: '4x' }] }, |
||||
|
{ duration: 2, layers: [{ type: 'video', path: './IMG_4605.MOV', cutFrom: 0, cutTo: 1 }, { type: 'title-background', text: '1/2x' }] }, |
||||
|
], |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
{ |
||||
|
fast: true, |
||||
|
outPath: './transitionEasing.mp4', |
||||
|
defaults: { |
||||
|
duration: 2, |
||||
|
}, |
||||
|
clips: [ |
||||
|
{ transition: { name: 'directional', duration: 0.5 }, layers: [{ type: 'video', path: '/Users/mifi/Desktop/photos/drone koh lipe/DJI_0402.MOV', cutTo: 2 }] }, |
||||
|
{ transition: { name: 'directional', duration: 0.5, params: { direction: [1, 0] } }, layers: [{ type: 'video', path: '/Users/mifi/Desktop/photos/drone koh lipe/DJI_0403.MOV', cutTo: 2 }] }, |
||||
|
// { transition: { name: 'directional', duration: 0.5, easing: null }, layers: [{ type: 'video', path: '/Users/mifi/Desktop/photos/drone koh lipe/DJI_0403.MOV', cutTo: 2 }] }, |
||||
|
{ layers: [{ type: 'pause' }] }, |
||||
|
], |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
{ |
||||
|
// fast: true, |
||||
|
outPath: './transparentGradient.mp4', |
||||
|
clips: [ |
||||
|
{ duration: 0.1, layers: [{ type: 'fill-color', color: 'green' }, { type: 'linear-gradient', colors: ['#ffffffff', '#ffffff00'] }] }, |
||||
|
], |
||||
|
} |
||||
@ -0,0 +1,71 @@ |
|||||
|
const GL = require('gl'); |
||||
|
const ndarray = require('ndarray'); |
||||
|
const createBuffer = require('gl-buffer'); |
||||
|
const transitions = require('gl-transitions'); |
||||
|
const createTransition = require('gl-transition').default; |
||||
|
const createTexture = require('gl-texture2d'); |
||||
|
|
||||
|
module.exports = ({ width, height, channels }) => { |
||||
|
const gl = GL(width, height); |
||||
|
|
||||
|
function runTransitionOnFrame({ fromFrame, toFrame, progress, transitionName, transitionParams = {} }) { |
||||
|
function convertFrame(buf) { |
||||
|
// @see https://github.com/stackgl/gl-texture2d/issues/16
|
||||
|
return ndarray(buf, [width, height, channels], [channels, width * channels, 1]); |
||||
|
} |
||||
|
|
||||
|
const buffer = createBuffer(gl, |
||||
|
[-1, -1, -1, 4, 4, -1], |
||||
|
gl.ARRAY_BUFFER, |
||||
|
gl.STATIC_DRAW); |
||||
|
|
||||
|
let transition; |
||||
|
|
||||
|
try { |
||||
|
const resizeMode = 'stretch'; |
||||
|
|
||||
|
const transitionSource = transitions.find((t) => t.name.toLowerCase() === transitionName.toLowerCase()); |
||||
|
|
||||
|
transition = createTransition(gl, transitionSource, { resizeMode }); |
||||
|
|
||||
|
gl.clear(gl.COLOR_BUFFER_BIT); |
||||
|
|
||||
|
// console.time('runTransitionOnFrame internal');
|
||||
|
const fromFrameNdArray = convertFrame(fromFrame); |
||||
|
const textureFrom = createTexture(gl, fromFrameNdArray); |
||||
|
textureFrom.minFilter = gl.LINEAR; |
||||
|
textureFrom.magFilter = gl.LINEAR; |
||||
|
|
||||
|
// console.timeLog('runTransitionOnFrame internal');
|
||||
|
const toFrameNdArray = convertFrame(toFrame); |
||||
|
const textureTo = createTexture(gl, toFrameNdArray); |
||||
|
textureTo.minFilter = gl.LINEAR; |
||||
|
textureTo.magFilter = gl.LINEAR; |
||||
|
|
||||
|
buffer.bind(); |
||||
|
transition.draw(progress, textureFrom, textureTo, gl.drawingBufferWidth, gl.drawingBufferHeight, transitionParams); |
||||
|
|
||||
|
textureFrom.dispose(); |
||||
|
textureTo.dispose(); |
||||
|
|
||||
|
// console.timeLog('runTransitionOnFrame internal');
|
||||
|
|
||||
|
const outArray = Buffer.allocUnsafe(width * height * 4); |
||||
|
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, outArray); |
||||
|
|
||||
|
// console.timeEnd('runTransitionOnFrame internal');
|
||||
|
|
||||
|
return outArray; |
||||
|
|
||||
|
// require('fs').writeFileSync(`${new Date().getTime()}.raw`, outArray);
|
||||
|
// Testing: ffmpeg -f rawvideo -vcodec rawvideo -pix_fmt rgba -s 2166x1650 -i 1586619627191.raw -vf format=yuv420p -vcodec libx264 -y out.mp4
|
||||
|
} finally { |
||||
|
buffer.dispose(); |
||||
|
if (transition) transition.dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
runTransitionOnFrame, |
||||
|
}; |
||||
|
}; |
||||
@ -0,0 +1,388 @@ |
|||||
|
const execa = require('execa'); |
||||
|
const assert = require('assert'); |
||||
|
const pMap = require('p-map'); |
||||
|
const { basename, join } = require('path'); |
||||
|
const flatMap = require('lodash/flatMap'); |
||||
|
const JSON5 = require('json5'); |
||||
|
|
||||
|
const { parseFps, readFileInfo, multipleOf2 } = require('./util'); |
||||
|
const { registerFont } = require('./sources/fabricFrameSource'); |
||||
|
const { createFrameSource } = require('./sources/frameSource'); |
||||
|
const { calcTransition } = require('./transitions'); |
||||
|
|
||||
|
const GlTransitions = require('./glTransitions'); |
||||
|
|
||||
|
// Cache
|
||||
|
const loadedFonts = []; |
||||
|
|
||||
|
|
||||
|
module.exports = async (config = {}) => { |
||||
|
const { |
||||
|
// Testing options:
|
||||
|
enableFfmpegLog = false, |
||||
|
verbose = false, |
||||
|
fast, |
||||
|
|
||||
|
outPath, |
||||
|
clips: clipsIn, |
||||
|
width: requestedWidth, |
||||
|
height: requestedHeight, |
||||
|
fps: requestedFps, |
||||
|
defaults: defaultsIn = {}, |
||||
|
audioFilePath: audioFilePathIn, |
||||
|
} = config; |
||||
|
|
||||
|
const isGif = outPath.toLowerCase().endsWith('.gif'); |
||||
|
|
||||
|
const audioFilePath = isGif ? undefined : audioFilePathIn; |
||||
|
|
||||
|
const defaults = { |
||||
|
duration: 4, |
||||
|
...defaultsIn, |
||||
|
transition: defaultsIn.transition === null ? null : { |
||||
|
duration: 0.5, |
||||
|
name: 'random', |
||||
|
...defaultsIn.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; |
||||
|
|
||||
|
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; |
||||
|
|
||||
|
// 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 (type === 'title' || type === 'subtitle') { |
||||
|
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) => { |
||||
|
const { transition: userTransition, duration: userDuration, layers } = { ...clip }; |
||||
|
|
||||
|
const videoLayers = layers.filter((layer) => layer.type === 'video'); |
||||
|
assert(videoLayers.length <= 1, 'Max 1 video per layer'); |
||||
|
|
||||
|
const userOrDefaultDuration = userDuration || defaults.duration; |
||||
|
if (videoLayers.length === 0) assert(userOrDefaultDuration, `Duration is required for clip ${clipIndex}`); |
||||
|
|
||||
|
let duration = userOrDefaultDuration; |
||||
|
|
||||
|
const layersOut = flatMap(await pMap(layers, async (layerIn) => { |
||||
|
const layer = { ...defaults.layer, ...layerIn }; |
||||
|
const { type } = layer; |
||||
|
|
||||
|
if (type === 'video') { |
||||
|
const { cutFrom: cutFromIn, cutTo: cutToIn, path } = layer; |
||||
|
const fileInfo = await readFileInfo(path); |
||||
|
const { duration: fileDuration, width, height, framerateStr } = fileInfo; |
||||
|
let cutFrom; |
||||
|
let cutTo; |
||||
|
let trimmedSourceDuration = fileDuration; |
||||
|
if (cutFromIn != null || cutToIn != null) { |
||||
|
cutFrom = Math.min(Math.max(0, cutFromIn || 0), fileDuration); |
||||
|
cutTo = Math.min(Math.max(cutFrom, cutToIn || fileDuration), fileDuration); |
||||
|
assert(cutFrom < cutTo, 'cutFrom must be lower than cutTo'); |
||||
|
|
||||
|
trimmedSourceDuration = cutTo - cutFrom; |
||||
|
} |
||||
|
|
||||
|
// If user specified duration, means that should be the output duration
|
||||
|
let framePtsFactor; |
||||
|
if (userDuration) { |
||||
|
duration = userDuration; |
||||
|
framePtsFactor = userDuration / trimmedSourceDuration; |
||||
|
} else { |
||||
|
duration = trimmedSourceDuration; |
||||
|
framePtsFactor = 1; |
||||
|
} |
||||
|
|
||||
|
return { ...layer, cutFrom, cutTo, width, height, framerateStr, framePtsFactor }; |
||||
|
} |
||||
|
|
||||
|
return handleLayer(layer); |
||||
|
}, { concurrency: 1 })); |
||||
|
|
||||
|
const transition = calcTransition(defaults, userTransition); |
||||
|
|
||||
|
return { |
||||
|
transition, |
||||
|
duration, |
||||
|
layers: layersOut, |
||||
|
}; |
||||
|
}, { concurrency: 1 }); |
||||
|
|
||||
|
if (verbose) console.log(JSON5.stringify(clips, null, 2)); |
||||
|
|
||||
|
// Try to detect parameters from first video
|
||||
|
let detectedWidth; |
||||
|
let detectedHeight; |
||||
|
let firstVideoFramerateStr; |
||||
|
|
||||
|
clips.find((clip) => clip && clip.layers.find((layer) => { |
||||
|
if (layer.type === 'video') { |
||||
|
detectedWidth = layer.width; |
||||
|
detectedHeight = layer.height; |
||||
|
firstVideoFramerateStr = layer.framerateStr; |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
return false; |
||||
|
})); |
||||
|
|
||||
|
let width; |
||||
|
let height; |
||||
|
|
||||
|
let desiredWidth; |
||||
|
if (requestedWidth) desiredWidth = requestedWidth; |
||||
|
else if (fast) desiredWidth = 320; |
||||
|
else if (isGif) desiredWidth = 320; |
||||
|
else desiredWidth = 640; |
||||
|
|
||||
|
if (detectedWidth && detectedHeight) { |
||||
|
const calculatedHeight = Math.round((detectedHeight / detectedWidth) * desiredWidth); |
||||
|
height = isGif ? calculatedHeight : multipleOf2(calculatedHeight); // x264 requires multiple of 2
|
||||
|
width = desiredWidth; |
||||
|
} else { |
||||
|
// Cannot detect width/height from video, set defaults
|
||||
|
width = desiredWidth; |
||||
|
height = desiredWidth; |
||||
|
} |
||||
|
|
||||
|
// User override?
|
||||
|
if (requestedWidth && requestedHeight) { |
||||
|
width = requestedWidth; |
||||
|
height = requestedHeight; |
||||
|
} |
||||
|
|
||||
|
assert(width, 'Width not specified or detected'); |
||||
|
assert(height, 'Height not specified or detected'); |
||||
|
|
||||
|
let fps; |
||||
|
let framerateStr; |
||||
|
|
||||
|
if (requestedFps && typeof requestedFps === 'number') { |
||||
|
fps = requestedFps; |
||||
|
framerateStr = String(requestedFps); |
||||
|
} else if (fast) { |
||||
|
fps = 15; |
||||
|
framerateStr = String(fps); |
||||
|
} else if (isGif) { |
||||
|
fps = 10; |
||||
|
framerateStr = String(fps); |
||||
|
} else if (firstVideoFramerateStr) { |
||||
|
fps = parseFps(firstVideoFramerateStr); |
||||
|
framerateStr = firstVideoFramerateStr; |
||||
|
} else { |
||||
|
fps = 25; |
||||
|
framerateStr = String(fps); |
||||
|
} |
||||
|
|
||||
|
assert(fps, 'FPS not specified or detected'); |
||||
|
|
||||
|
console.log(`${width}x${height} ${fps}fps`); |
||||
|
|
||||
|
const channels = 4; |
||||
|
|
||||
|
const { runTransitionOnFrame } = GlTransitions({ width, height, channels }); |
||||
|
|
||||
|
function startFfmpegWriterProcess() { |
||||
|
// https://superuser.com/questions/556029/how-do-i-convert-a-video-to-gif-using-ffmpeg-with-reasonable-quality
|
||||
|
const outputArgs = isGif ? [ |
||||
|
'-vf', |
||||
|
`fps=${fps},scale=${width}:${height}:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`, |
||||
|
'-loop', 0, |
||||
|
'-y', outPath, |
||||
|
] : [ |
||||
|
'-vf', 'format=yuv420p', |
||||
|
'-vcodec', 'libx264', |
||||
|
'-profile:v', 'high', |
||||
|
...(fast ? ['-preset:v', 'ultrafast'] : ['-preset:v', 'medium']), |
||||
|
'-crf', '18', |
||||
|
|
||||
|
'-movflags', 'faststart', |
||||
|
'-y', outPath, |
||||
|
]; |
||||
|
const args = [ |
||||
|
...(enableFfmpegLog ? [] : ['-hide_banner', '-loglevel', 'panic']), |
||||
|
|
||||
|
'-f', 'rawvideo', |
||||
|
'-vcodec', 'rawvideo', |
||||
|
'-pix_fmt', 'rgba', |
||||
|
'-s', `${width}x${height}`, |
||||
|
'-r', framerateStr, |
||||
|
'-i', '-', |
||||
|
|
||||
|
...(audioFilePath ? ['-i', audioFilePath, '-shortest'] : []), |
||||
|
...(audioFilePath ? ['-acodec', 'aac', '-b:a', '128k'] : []), |
||||
|
|
||||
|
...outputArgs, |
||||
|
]; |
||||
|
if (verbose) console.log('ffmpeg', args.join(' ')); |
||||
|
return execa('ffmpeg', args, { encoding: null, buffer: false, stdin: 'pipe', stdout: process.stdout, stderr: process.stderr }); |
||||
|
} |
||||
|
|
||||
|
let outProcess; |
||||
|
let frameSource1; |
||||
|
let frameSource2; |
||||
|
|
||||
|
try { |
||||
|
outProcess = startFfmpegWriterProcess(); |
||||
|
|
||||
|
let totalFrameCount = 0; |
||||
|
let fromClipFrameCount = 0; |
||||
|
let toClipFrameCount = 0; |
||||
|
|
||||
|
let transitionFromClipId = 0; |
||||
|
|
||||
|
const getTransitionToClipId = () => transitionFromClipId + 1; |
||||
|
const getTransitionFromClip = () => clips[transitionFromClipId]; |
||||
|
const getTransitionToClip = () => clips[getTransitionToClipId()]; |
||||
|
|
||||
|
const getSource = (clip, clipIndex) => createFrameSource({ clip, clipIndex, width, height, channels, verbose, enableFfmpegLog, framerateStr }); |
||||
|
|
||||
|
const getTransitionToSource = async () => (getTransitionToClip() && getSource(getTransitionToClip(), getTransitionToClipId())); |
||||
|
frameSource1 = await getSource(getTransitionFromClip(), transitionFromClipId); |
||||
|
frameSource2 = await getTransitionToSource(); |
||||
|
|
||||
|
// eslint-disable-next-line no-constant-condition
|
||||
|
while (true) { |
||||
|
const fromClipNumFrames = Math.round(getTransitionFromClip().duration * fps); |
||||
|
const toClipNumFrames = getTransitionToClip() && Math.round(getTransitionToClip().duration * fps); |
||||
|
const fromClipProgress = fromClipFrameCount / fromClipNumFrames; |
||||
|
const toClipProgress = getTransitionToClip() && toClipFrameCount / toClipNumFrames; |
||||
|
const frameData1 = await frameSource1.readNextFrame(fromClipProgress); |
||||
|
|
||||
|
const clipTransition = getTransitionFromClip().transition; |
||||
|
|
||||
|
const transitionNumFrames = Math.round((clipTransition.duration || 0) * fps); |
||||
|
|
||||
|
// Each clip has two transitions, make sure we leave enough room:
|
||||
|
const transitionNumFramesSafe = Math.floor(Math.min(Math.min(fromClipNumFrames, toClipNumFrames != null ? toClipNumFrames : Number.MAX_SAFE_INTEGER) / 2, transitionNumFrames)); |
||||
|
// How many frames into the transition are we? negative means not yet started
|
||||
|
const transitionFrameAt = fromClipFrameCount - (fromClipNumFrames - transitionNumFramesSafe); |
||||
|
|
||||
|
if (verbose) console.log('Frame', totalFrameCount, 'from', fromClipFrameCount, `(clip ${transitionFromClipId})`, 'to', toClipFrameCount, `(clip ${getTransitionToClipId()})`); |
||||
|
|
||||
|
if (!frameData1 || transitionFrameAt >= transitionNumFramesSafe - 1) { |
||||
|
// if (!frameData1 || transitionFrameAt >= transitionNumFramesSafe) {
|
||||
|
console.log('Done with transition, switching to next clip'); |
||||
|
transitionFromClipId += 1; |
||||
|
|
||||
|
if (!getTransitionFromClip()) { |
||||
|
console.log('No more transitionFromClip, done'); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
// Cleanup old, swap and load next
|
||||
|
await frameSource1.close(); |
||||
|
frameSource1 = frameSource2; |
||||
|
frameSource2 = await getTransitionToSource(); |
||||
|
|
||||
|
fromClipFrameCount = transitionNumFramesSafe; |
||||
|
toClipFrameCount = 0; |
||||
|
} else { |
||||
|
let outFrameData; |
||||
|
if (frameSource2 && transitionFrameAt >= 0) { |
||||
|
if (verbose) console.log('Transition', 'frame', transitionFrameAt, '/', transitionNumFramesSafe, clipTransition.name, `${clipTransition.duration}s`); |
||||
|
|
||||
|
const frameData2 = await frameSource2.readNextFrame(toClipProgress); |
||||
|
toClipFrameCount += 1; |
||||
|
|
||||
|
if (frameData2) { |
||||
|
const progress = transitionFrameAt / transitionNumFramesSafe; |
||||
|
const easedProgress = clipTransition.easingFunction(progress); |
||||
|
|
||||
|
if (verbose) console.time('runTransitionOnFrame'); |
||||
|
outFrameData = runTransitionOnFrame({ fromFrame: frameData1, toFrame: frameData2, progress: easedProgress, transitionName: clipTransition.name, transitionParams: clipTransition.params }); |
||||
|
if (verbose) console.timeEnd('runTransitionOnFrame'); |
||||
|
} else { |
||||
|
console.warn('Got no frame data from clip 2!'); |
||||
|
// We have reached end of clip2 but transition is not complete
|
||||
|
// Pass thru
|
||||
|
// TODO improve, maybe cut it short
|
||||
|
outFrameData = frameData1; |
||||
|
} |
||||
|
} else { |
||||
|
outFrameData = frameData1; |
||||
|
} |
||||
|
|
||||
|
// If we don't await we get EINVAL when dealing with high resolution files (big writes)
|
||||
|
await new Promise((r) => outProcess.stdin.write(outFrameData, () => r())); |
||||
|
// outProcess.stdin.write(outFrameData);
|
||||
|
|
||||
|
fromClipFrameCount += 1; |
||||
|
} |
||||
|
|
||||
|
totalFrameCount += 1; |
||||
|
} |
||||
|
|
||||
|
outProcess.stdin.end(); |
||||
|
|
||||
|
console.log('Done. Output file can be found at:'); |
||||
|
console.log(outPath); |
||||
|
} catch (err) { |
||||
|
console.error('Loop failed', err); |
||||
|
if (outProcess) { |
||||
|
outProcess.kill(); |
||||
|
} |
||||
|
} finally { |
||||
|
if (frameSource1) await frameSource1.close(); |
||||
|
if (frameSource2) await frameSource2.close(); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
await outProcess; |
||||
|
} catch (err) { |
||||
|
if (!err.killed) throw err; |
||||
|
} |
||||
|
}; |
||||
@ -0,0 +1,42 @@ |
|||||
|
{ |
||||
|
"name": "editly", |
||||
|
"description": "Simple, sexy, declarative video editing", |
||||
|
"version": "0.1.0", |
||||
|
"main": "index.js", |
||||
|
"author": "Mikael Finstad <finstaden@gmail.com>", |
||||
|
"license": "MIT", |
||||
|
"dependencies": { |
||||
|
"canvas": "^2.6.1", |
||||
|
"execa": "^4.0.0", |
||||
|
"fabric": "^3.6.3", |
||||
|
"file-type": "^14.1.4", |
||||
|
"file-url": "^3.0.0", |
||||
|
"fs-extra": "^9.0.0", |
||||
|
"gl": "^4.5.0", |
||||
|
"gl-buffer": "^2.1.2", |
||||
|
"gl-shader": "^4.2.1", |
||||
|
"gl-texture2d": "^2.1.0", |
||||
|
"gl-transition": "^1.13.0", |
||||
|
"gl-transitions": "^1.43.0", |
||||
|
"json5": "^2.1.3", |
||||
|
"lodash": "^4.17.15", |
||||
|
"meow": "^6.1.0", |
||||
|
"ndarray": "^1.0.19", |
||||
|
"p-map": "^4.0.0" |
||||
|
}, |
||||
|
"scripts": { |
||||
|
"test": "exit 0" |
||||
|
}, |
||||
|
"repository": { |
||||
|
"type": "git", |
||||
|
"url": "git+https://github.com/mifi/editly.git" |
||||
|
}, |
||||
|
"bin": { |
||||
|
"editly": "cli.js" |
||||
|
}, |
||||
|
"devDependencies": { |
||||
|
"eslint": "^5.16.0 || ^6.8.0", |
||||
|
"eslint-config-airbnb-base": "^14.1.0", |
||||
|
"eslint-plugin-import": "^2.20.1" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
#ifdef GL_ES |
||||
|
precision mediump float; |
||||
|
#endif |
||||
|
|
||||
|
uniform float time; |
||||
|
uniform vec2 resolution; |
||||
|
|
||||
|
void main() { |
||||
|
vec2 st = gl_FragCoord.xy/resolution.xy; |
||||
|
st.x *= resolution.x/resolution.y; |
||||
|
|
||||
|
vec3 color = vec3(0.); |
||||
|
color = vec3(st.x,st.y,abs(sin(time))); |
||||
|
|
||||
|
gl_FragColor = vec4(color,1.0); |
||||
|
} |
||||
@ -0,0 +1,331 @@ |
|||||
|
const { fabric } = require('fabric'); |
||||
|
const fileUrl = require('file-url'); |
||||
|
const nodeCanvas = require('canvas'); |
||||
|
|
||||
|
const { createCanvas } = nodeCanvas; |
||||
|
|
||||
|
const { canvasToRgba } = require('./shared'); |
||||
|
const { getRandomGradient, getRandomColors } = require('../colors'); |
||||
|
const { easeOutExpo } = require('../transitions'); |
||||
|
|
||||
|
// http://fabricjs.com/kitchensink
|
||||
|
|
||||
|
|
||||
|
function fabricCanvasToRgba(canvas) { |
||||
|
// https://github.com/fabricjs/fabric.js/blob/26e1a5b55cbeeffb59845337ced3f3f91d533d7d/src/static_canvas.class.js
|
||||
|
// https://github.com/fabricjs/fabric.js/issues/3885
|
||||
|
const internalCanvas = fabric.util.getNodeCanvas(canvas.lowerCanvasEl); |
||||
|
const ctx = internalCanvas.getContext('2d'); |
||||
|
|
||||
|
// require('fs').writeFileSync(`${Math.floor(Math.random() * 1e12)}.png`, internalCanvas.toBuffer('image/png'));
|
||||
|
// throw new Error('abort');
|
||||
|
|
||||
|
return canvasToRgba(ctx); |
||||
|
} |
||||
|
|
||||
|
async function mergeFrames({ width, height, framesRaw }) { |
||||
|
if (framesRaw.length === 1) return framesRaw[0]; |
||||
|
|
||||
|
// Node canvas needs no cleanup https://github.com/Automattic/node-canvas/issues/1216#issuecomment-412390668
|
||||
|
const canvas = createCanvas(width, height); |
||||
|
const ctx = canvas.getContext('2d'); |
||||
|
|
||||
|
framesRaw.forEach((frameRaw) => { |
||||
|
const canvas2 = createCanvas(width, height); |
||||
|
const ctx2 = canvas2.getContext('2d'); |
||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/ImageData/ImageData
|
||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/putImageData
|
||||
|
ctx2.putImageData(new nodeCanvas.ImageData(Uint8ClampedArray.from(frameRaw), width, height), 0, 0); |
||||
|
// require('fs').writeFileSync(`${Math.floor(Math.random() * 1e12)}.png`, canvas2.toBuffer('image/png'));
|
||||
|
|
||||
|
ctx.drawImage(canvas2, 0, 0); |
||||
|
}); |
||||
|
|
||||
|
return canvasToRgba(ctx); |
||||
|
} |
||||
|
|
||||
|
async function createFabricFrameSource(func, { width, height, ...rest }) { |
||||
|
const onInit = async ({ canvas }) => func(({ width, height, fabric, canvas, ...rest })); |
||||
|
|
||||
|
let canvas = new fabric.StaticCanvas(null, { width, height }); |
||||
|
|
||||
|
const { onRender = () => {}, onClose = () => {} } = await onInit({ canvas }) || {}; |
||||
|
|
||||
|
async function readNextFrame(progress) { |
||||
|
await onRender(progress); |
||||
|
|
||||
|
canvas.renderAll(); |
||||
|
|
||||
|
const rgba = fabricCanvasToRgba(canvas); |
||||
|
|
||||
|
canvas.clear(); |
||||
|
// canvas.dispose();
|
||||
|
return rgba; |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
readNextFrame, |
||||
|
close: () => { |
||||
|
// https://stackoverflow.com/questions/19030174/how-to-manage-memory-in-case-of-multiple-fabric-js-canvas
|
||||
|
canvas.dispose(); |
||||
|
canvas = undefined; |
||||
|
onClose(); |
||||
|
}, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
async function imageFrameSource({ verbose, params, width, height, canvas }) { |
||||
|
if (verbose) console.log('Loading', params.path); |
||||
|
|
||||
|
const imgData = await new Promise((resolve) => fabric.util.loadImage(fileUrl(params.path), resolve)); |
||||
|
|
||||
|
const getImg = () => new fabric.Image(imgData, { |
||||
|
originX: 'center', |
||||
|
originY: 'center', |
||||
|
left: width / 2, |
||||
|
top: height / 2, |
||||
|
}); |
||||
|
|
||||
|
// Blurred version
|
||||
|
const blurredImg = getImg(); |
||||
|
blurredImg.filters = [new fabric.Image.filters.Resize({ scaleX: 0.01, scaleY: 0.01 })]; |
||||
|
blurredImg.applyFilters(); |
||||
|
|
||||
|
if (blurredImg.height > blurredImg.width) blurredImg.scaleToWidth(width); |
||||
|
else blurredImg.scaleToHeight(height); |
||||
|
|
||||
|
|
||||
|
async function onRender(progress) { |
||||
|
const { zoomDirection = 'in', zoomAmount = 0.1 } = params; |
||||
|
|
||||
|
const img = getImg(); |
||||
|
|
||||
|
const scaleFactor = zoomDirection === 'in' ? (1 + progress * zoomAmount) : (1 + zoomAmount * (1 - progress)); |
||||
|
if (img.height > img.width) img.scaleToHeight(height * scaleFactor); |
||||
|
else img.scaleToWidth(width * scaleFactor); |
||||
|
|
||||
|
canvas.add(blurredImg); |
||||
|
canvas.add(img); |
||||
|
} |
||||
|
|
||||
|
function onClose() { |
||||
|
blurredImg.dispose(); |
||||
|
// imgData.dispose();
|
||||
|
} |
||||
|
|
||||
|
return { onRender, onClose }; |
||||
|
} |
||||
|
|
||||
|
async function fillColorFrameSource({ canvas, params }) { |
||||
|
const { color } = params; |
||||
|
|
||||
|
const randomColor = getRandomColors(1)[0]; |
||||
|
|
||||
|
async function onRender() { |
||||
|
// eslint-disable-next-line no-param-reassign
|
||||
|
canvas.backgroundColor = color || randomColor; |
||||
|
} |
||||
|
|
||||
|
return { onRender }; |
||||
|
} |
||||
|
|
||||
|
function getRekt(width, height) { |
||||
|
// width and height with room to rotate
|
||||
|
return new fabric.Rect({ originX: 'center', originY: 'center', left: width / 2, top: height / 2, width: width * 2, height: height * 2 }); |
||||
|
} |
||||
|
|
||||
|
async function radialGradientFrameSource({ canvas, width, height, params }) { |
||||
|
const { colors: inColors } = params; |
||||
|
|
||||
|
const randomColors = getRandomGradient(); |
||||
|
|
||||
|
async function onRender(progress) { |
||||
|
// console.log('progress', progress);
|
||||
|
|
||||
|
const max = Math.max(width, height); |
||||
|
|
||||
|
const colors = inColors && inColors.length === 2 ? inColors : randomColors; |
||||
|
|
||||
|
const r1 = 0; |
||||
|
const r2 = max * (1 + progress) * 0.6; |
||||
|
|
||||
|
const rect = getRekt(width, height); |
||||
|
|
||||
|
const cx = 0.5 * rect.width; |
||||
|
const cy = 0.5 * rect.height; |
||||
|
|
||||
|
rect.setGradient('fill', { |
||||
|
type: 'radial', |
||||
|
r1, |
||||
|
r2, |
||||
|
x1: cx, |
||||
|
y1: cy, |
||||
|
x2: cx, |
||||
|
y2: cy, |
||||
|
colorStops: { |
||||
|
0: colors[0], |
||||
|
1: colors[1], |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
canvas.add(rect); |
||||
|
} |
||||
|
|
||||
|
return { onRender }; |
||||
|
} |
||||
|
|
||||
|
async function linearGradientFrameSource({ canvas, width, height, params }) { |
||||
|
const { colors: inColors } = params; |
||||
|
|
||||
|
const randomColors = getRandomGradient(); |
||||
|
const colors = inColors && inColors.length === 2 ? inColors : randomColors; |
||||
|
|
||||
|
async function onRender(progress) { |
||||
|
const rect = getRekt(width, height); |
||||
|
|
||||
|
rect.setGradient('fill', { |
||||
|
x1: 0, |
||||
|
y1: 0, |
||||
|
x2: width, |
||||
|
y2: height, |
||||
|
colorStops: { |
||||
|
0: colors[0], |
||||
|
1: colors[1], |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
rect.rotate(progress * 30); |
||||
|
canvas.add(rect); |
||||
|
} |
||||
|
|
||||
|
return { onRender }; |
||||
|
} |
||||
|
|
||||
|
async function subtitleFrameSource({ canvas, width, height, params }) { |
||||
|
const { text, textColor = '#ffffff', fontFamily = 'sans-serif' } = params; |
||||
|
|
||||
|
async function onRender(progress) { |
||||
|
const easedProgress = easeOutExpo(Math.min(progress, 1)); |
||||
|
|
||||
|
const min = Math.min(width, height); |
||||
|
const padding = 0.05 * min; |
||||
|
|
||||
|
const textBox = new fabric.Textbox(text, { |
||||
|
fill: textColor, |
||||
|
fontFamily, |
||||
|
|
||||
|
fontSize: min / 20, |
||||
|
textAlign: 'left', |
||||
|
width: width - padding * 2, |
||||
|
originX: 'center', |
||||
|
originY: 'bottom', |
||||
|
left: (width / 2) + (-1 + easedProgress) * padding, |
||||
|
top: height - padding, |
||||
|
opacity: easedProgress, |
||||
|
}); |
||||
|
|
||||
|
const rect = new fabric.Rect({ |
||||
|
left: 0, |
||||
|
width, |
||||
|
height: textBox.height + padding * 2, |
||||
|
top: height, |
||||
|
originY: 'bottom', |
||||
|
fill: 'rgba(0,0,0,0.2)', |
||||
|
opacity: easedProgress, |
||||
|
}); |
||||
|
|
||||
|
canvas.add(rect); |
||||
|
canvas.add(textBox); |
||||
|
} |
||||
|
|
||||
|
return { onRender }; |
||||
|
} |
||||
|
|
||||
|
async function titleFrameSource({ canvas, width, height, params }) { |
||||
|
const { text, textColor = '#ffffff', fontFamily = 'sans-serif', position = 'center' } = params; |
||||
|
|
||||
|
async function onRender(progress) { |
||||
|
// console.log('progress', progress);
|
||||
|
|
||||
|
const min = Math.min(width, height); |
||||
|
|
||||
|
const fontSize = Math.round(min * 0.1); |
||||
|
|
||||
|
const scale = (1 + progress * 0.2).toFixed(4); |
||||
|
|
||||
|
const textBox = new fabric.Textbox(text, { |
||||
|
fill: textColor, |
||||
|
fontFamily, |
||||
|
fontSize, |
||||
|
textAlign: 'center', |
||||
|
width: width * 0.8, |
||||
|
}); |
||||
|
|
||||
|
const textImage = await new Promise((r) => textBox.cloneAsImage(r)); |
||||
|
|
||||
|
let originY = 'center'; |
||||
|
let top = height / 2; |
||||
|
if (position === 'top') { |
||||
|
originY = 'top'; |
||||
|
top = height * 0.05; |
||||
|
} else if (position === 'bottom') { |
||||
|
originY = 'bottom'; |
||||
|
top = height; |
||||
|
} |
||||
|
|
||||
|
textImage.set({ |
||||
|
originX: 'center', |
||||
|
originY, |
||||
|
left: width / 2, |
||||
|
top, |
||||
|
scaleX: scale, |
||||
|
scaleY: scale, |
||||
|
}); |
||||
|
canvas.add(textImage); |
||||
|
} |
||||
|
|
||||
|
return { onRender }; |
||||
|
} |
||||
|
|
||||
|
async function createCustomCanvasFrameSource({ width, height, params }) { |
||||
|
const canvas = createCanvas(width, height); |
||||
|
const context = canvas.getContext('2d'); |
||||
|
|
||||
|
const { onClose, onRender } = await params.func(({ width, height, canvas })); |
||||
|
|
||||
|
async function readNextFrame(progress) { |
||||
|
context.clearRect(0, 0, canvas.width, canvas.height); |
||||
|
await onRender(progress); |
||||
|
// require('fs').writeFileSync(`${new Date().getTime()}.png`, canvas.toBuffer('image/png'));
|
||||
|
return canvasToRgba(context); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
readNextFrame, |
||||
|
// Node canvas needs no cleanup https://github.com/Automattic/node-canvas/issues/1216#issuecomment-412390668
|
||||
|
close: onClose, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
async function customFabricFrameSource({ canvas, width, height, params }) { |
||||
|
return params.func(({ width, height, fabric, canvas })); |
||||
|
} |
||||
|
|
||||
|
function registerFont(...args) { |
||||
|
fabric.nodeCanvas.registerFont(...args); |
||||
|
} |
||||
|
|
||||
|
module.exports = { |
||||
|
mergeFrames, |
||||
|
registerFont, |
||||
|
createFabricFrameSource, |
||||
|
createCustomCanvasFrameSource, |
||||
|
|
||||
|
customFabricFrameSource, |
||||
|
subtitleFrameSource, |
||||
|
titleFrameSource, |
||||
|
fillColorFrameSource, |
||||
|
radialGradientFrameSource, |
||||
|
linearGradientFrameSource, |
||||
|
imageFrameSource, |
||||
|
}; |
||||
@ -0,0 +1,61 @@ |
|||||
|
const assert = require('assert'); |
||||
|
const pMap = require('p-map'); |
||||
|
|
||||
|
const { mergeFrames, customFabricFrameSource, createCustomCanvasFrameSource, titleFrameSource, subtitleFrameSource, imageFrameSource, linearGradientFrameSource, radialGradientFrameSource, fillColorFrameSource, createFabricFrameSource } = require('./fabricFrameSource'); |
||||
|
const createVideoFrameSource = require('./videoFrameSource'); |
||||
|
const { createGlFrameSource } = require('./glFrameSource'); |
||||
|
|
||||
|
|
||||
|
async function createFrameSource({ clip, clipIndex, width, height, channels, verbose, enableFfmpegLog, framerateStr }) { |
||||
|
const { layers, duration } = clip; |
||||
|
|
||||
|
const frameSources = await pMap(layers, async (layer, layerIndex) => { |
||||
|
const { type, ...params } = layer; |
||||
|
console.log('createFrameSource', type, 'clip', clipIndex, 'layer', layerIndex); |
||||
|
|
||||
|
const frameSourceFuncs = { |
||||
|
video: createVideoFrameSource, |
||||
|
gl: createGlFrameSource, |
||||
|
canvas: createCustomCanvasFrameSource, |
||||
|
fabric: async (opts) => createFabricFrameSource(customFabricFrameSource, opts), |
||||
|
image: async (opts) => createFabricFrameSource(imageFrameSource, opts), |
||||
|
title: async (opts) => createFabricFrameSource(titleFrameSource, opts), |
||||
|
subtitle: async (opts) => createFabricFrameSource(subtitleFrameSource, opts), |
||||
|
'linear-gradient': async (opts) => createFabricFrameSource(linearGradientFrameSource, opts), |
||||
|
'radial-gradient': async (opts) => createFabricFrameSource(radialGradientFrameSource, opts), |
||||
|
'fill-color': async (opts) => createFabricFrameSource(fillColorFrameSource, opts), |
||||
|
}; |
||||
|
assert(frameSourceFuncs[type], `Invalid type ${type}`); |
||||
|
|
||||
|
const createFrameSourceFunc = frameSourceFuncs[type]; |
||||
|
|
||||
|
return createFrameSourceFunc({ width, height, duration, channels, verbose, enableFfmpegLog, framerateStr, params }); |
||||
|
}, { concurrency: 1 }); |
||||
|
|
||||
|
async function readNextFrame(...args) { |
||||
|
const framesRaw = await pMap(frameSources, async (frameSource) => frameSource.readNextFrame(...args)); |
||||
|
// if (verbose) console.time('Merge frames');
|
||||
|
|
||||
|
const framesRawFiltered = framesRaw.filter((frameRaw) => { |
||||
|
if (frameRaw) return true; |
||||
|
console.warn('Frame source returned empty result'); |
||||
|
return false; |
||||
|
}); |
||||
|
const merged = mergeFrames({ width, height, framesRaw: framesRawFiltered }); |
||||
|
// if (verbose) console.timeEnd('Merge frames');
|
||||
|
return merged; |
||||
|
} |
||||
|
|
||||
|
async function close() { |
||||
|
await pMap(frameSources, async (frameSource) => frameSource.close()); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
readNextFrame, |
||||
|
close, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
module.exports = { |
||||
|
createFrameSource, |
||||
|
}; |
||||
@ -0,0 +1,65 @@ |
|||||
|
const GL = require('gl'); |
||||
|
const createShader = require('gl-shader'); |
||||
|
const fs = require('fs-extra'); |
||||
|
|
||||
|
// I have no idea what I'm doing but it works ¯\_(ツ)_/¯
|
||||
|
|
||||
|
async function createGlFrameSource({ width, height, channels, params }) { |
||||
|
const gl = GL(width, height); |
||||
|
|
||||
|
const defaultVertexSrc = `
|
||||
|
attribute vec2 position; |
||||
|
void main(void) { |
||||
|
gl_Position = vec4(position, 0.0, 1.0 ); |
||||
|
} |
||||
|
`;
|
||||
|
const { vertexPath, fragmentPath, vertexSrc: vertexSrcIn, fragmentSrc: fragmentSrcIn, speed = 1 } = params; |
||||
|
|
||||
|
let fragmentSrc = fragmentSrcIn; |
||||
|
let vertexSrc = vertexSrcIn; |
||||
|
|
||||
|
if (fragmentPath) fragmentSrc = await fs.readFile(fragmentPath); |
||||
|
if (vertexPath) vertexSrc = await fs.readFile(vertexPath); |
||||
|
|
||||
|
if (!vertexSrc) vertexSrc = defaultVertexSrc; |
||||
|
|
||||
|
const shader = createShader(gl, vertexSrc, fragmentSrc); |
||||
|
const buffer = gl.createBuffer(); |
||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, buffer); |
||||
|
// https://blog.mayflower.de/4584-Playing-around-with-pixel-shaders-in-WebGL.html
|
||||
|
|
||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, 1, 1, -1, 1]), gl.STATIC_DRAW); |
||||
|
|
||||
|
async function readNextFrame(progress) { |
||||
|
shader.bind(); |
||||
|
|
||||
|
shader.attributes.position.pointer(); |
||||
|
|
||||
|
shader.uniforms.resolution = [width, height]; |
||||
|
shader.uniforms.time = progress * speed; |
||||
|
|
||||
|
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); |
||||
|
|
||||
|
const upsideDownArray = Buffer.allocUnsafe(width * height * channels); |
||||
|
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, upsideDownArray); |
||||
|
const outArray = Buffer.allocUnsafe(width * height * channels); |
||||
|
|
||||
|
// Comes out upside down, flip it
|
||||
|
for (let i = 0; i < outArray.length; i += 4) { |
||||
|
outArray[i + 0] = upsideDownArray[outArray.length - i + 0]; |
||||
|
outArray[i + 1] = upsideDownArray[outArray.length - i + 1]; |
||||
|
outArray[i + 2] = upsideDownArray[outArray.length - i + 2]; |
||||
|
outArray[i + 3] = upsideDownArray[outArray.length - i + 3]; |
||||
|
} |
||||
|
return outArray; |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
readNextFrame, |
||||
|
close: () => {}, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
module.exports = { |
||||
|
createGlFrameSource, |
||||
|
}; |
||||
@ -0,0 +1,21 @@ |
|||||
|
function canvasToRgba(ctx) { |
||||
|
// const bgra = canvas.toBuffer('raw');
|
||||
|
|
||||
|
/* const rgba = Buffer.allocUnsafe(bgra.length); |
||||
|
for (let i = 0; i < bgra.length; i += 4) { |
||||
|
rgba[i + 0] = bgra[i + 2]; |
||||
|
rgba[i + 1] = bgra[i + 1]; |
||||
|
rgba[i + 2] = bgra[i + 0]; |
||||
|
rgba[i + 3] = bgra[i + 3]; |
||||
|
} */ |
||||
|
|
||||
|
// We cannot use toBuffer('raw') because it returns pre-multiplied alpha data (a different format)
|
||||
|
// https://gamedev.stackexchange.com/questions/138813/whats-the-difference-between-alpha-and-premulalpha
|
||||
|
// https://github.com/Automattic/node-canvas#image-pixel-formats-experimental
|
||||
|
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); |
||||
|
return Buffer.from(imageData.data); |
||||
|
} |
||||
|
|
||||
|
module.exports = { |
||||
|
canvasToRgba, |
||||
|
}; |
||||
@ -0,0 +1,124 @@ |
|||||
|
const execa = require('execa'); |
||||
|
const assert = require('assert'); |
||||
|
|
||||
|
module.exports = async ({ width, height, channels, framerateStr, verbose, enableFfmpegLog, params }) => { |
||||
|
const targetSize = width * height * channels; |
||||
|
|
||||
|
// TODO assert that we have read the correct amount of frames
|
||||
|
|
||||
|
const { path, cutFrom, cutTo, resizeMode = 'cover', backgroundColor = '#000000', framePtsFactor } = params; |
||||
|
|
||||
|
const buf = Buffer.allocUnsafe(targetSize); |
||||
|
let length = 0; |
||||
|
// let inFrameCount = 0;
|
||||
|
|
||||
|
let ptsFilter = ''; |
||||
|
if (framePtsFactor !== 1) { |
||||
|
if (verbose) console.log('framePtsFactor', framePtsFactor); |
||||
|
ptsFilter = `setpts=${framePtsFactor}*PTS,`; |
||||
|
} |
||||
|
|
||||
|
let scaleFilter; |
||||
|
if (resizeMode === 'stretch') scaleFilter = `scale=${width}:${height}`; |
||||
|
// https://superuser.com/questions/891145/ffmpeg-upscale-and-letterbox-a-video/891478
|
||||
|
else if (resizeMode === 'contain') scaleFilter = `scale=(iw*sar)*min(${width}/(iw*sar)\\,${height}/ih):ih*min(${width}/(iw*sar)\\,${height}/ih), pad=${width}:${height}:(${width}-iw*min(${width}/iw\\,${height}/ih))/2:(${height}-ih*min(${width}/iw\\,${height}/ih))/2:${backgroundColor}`; |
||||
|
// Cover: https://unix.stackexchange.com/a/192123
|
||||
|
else scaleFilter = `scale=(iw*sar)*max(${width}/(iw*sar)\\,${height}/ih):ih*max(${width}/(iw*sar)\\,${height}/ih),crop=${width}:${height}`; |
||||
|
|
||||
|
// http://zulko.github.io/blog/2013/09/27/read-and-write-video-frames-in-python-using-ffmpeg/
|
||||
|
// Testing: ffmpeg -i 'vid.mov' -t 1 -vcodec rawvideo -pix_fmt rgba -f image2pipe - | ffmpeg -f rawvideo -vcodec rawvideo -pix_fmt rgba -s 2166x1650 -i - -vf format=yuv420p -vcodec libx264 -y out.mp4
|
||||
|
// https://trac.ffmpeg.org/wiki/ChangingFrameRate
|
||||
|
const args = [ |
||||
|
...(enableFfmpegLog ? [] : ['-hide_banner', '-loglevel', 'panic']), |
||||
|
...(cutFrom ? ['-ss', cutFrom] : []), |
||||
|
'-i', path, |
||||
|
...(cutTo ? ['-t', (cutTo - cutFrom) * framePtsFactor] : []), |
||||
|
'-vf', `${ptsFilter}fps=${framerateStr},${scaleFilter}`, |
||||
|
'-map', 'v:0', |
||||
|
'-vcodec', 'rawvideo', |
||||
|
'-pix_fmt', 'rgba', |
||||
|
'-f', 'image2pipe', |
||||
|
'-', |
||||
|
]; |
||||
|
if (verbose) console.log(args.join(' ')); |
||||
|
|
||||
|
const ps = execa('ffmpeg', args, { encoding: null, buffer: false, stdin: 'ignore', stdout: 'pipe', stderr: process.stderr }); |
||||
|
|
||||
|
const stream = ps.stdout; |
||||
|
|
||||
|
let timeout; |
||||
|
let ended = false; |
||||
|
|
||||
|
const readNextFrame = () => new Promise((resolve, reject) => { |
||||
|
if (ended) { |
||||
|
resolve(); |
||||
|
return; |
||||
|
} |
||||
|
// console.log('Reading new frame', path);
|
||||
|
|
||||
|
function onEnd() { |
||||
|
if (verbose) console.log(path, 'ffmpeg stream ended'); |
||||
|
ended = true; |
||||
|
resolve(); |
||||
|
} |
||||
|
|
||||
|
function handleChunk(chunk) { |
||||
|
// console.log('chunk', chunk.length);
|
||||
|
const nCopied = length + chunk.length > targetSize ? targetSize - length : chunk.length; |
||||
|
chunk.copy(buf, length, 0, nCopied); |
||||
|
length += nCopied; |
||||
|
|
||||
|
if (length > targetSize) console.error('OOPS! Overflow', length); |
||||
|
|
||||
|
if (length >= targetSize) { |
||||
|
// console.log('Finished reading frame', inFrameCount, path);
|
||||
|
const out = Buffer.from(buf); |
||||
|
|
||||
|
const restLength = chunk.length - nCopied; |
||||
|
if (restLength > 0) { |
||||
|
if (verbose) console.log('Left over data', nCopied, chunk.length, restLength); |
||||
|
chunk.slice(nCopied).copy(buf, 0); |
||||
|
length = restLength; |
||||
|
} else { |
||||
|
length = 0; |
||||
|
} |
||||
|
|
||||
|
// inFrameCount += 1;
|
||||
|
|
||||
|
clearTimeout(timeout); |
||||
|
stream.pause(); |
||||
|
stream.removeListener('data', handleChunk); |
||||
|
stream.removeListener('end', onEnd); |
||||
|
stream.removeListener('error', reject); |
||||
|
resolve(out); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
timeout = setTimeout(() => { |
||||
|
console.warn('Timeout on read video frame'); |
||||
|
stream.pause(); |
||||
|
stream.removeListener('data', handleChunk); |
||||
|
stream.removeListener('end', onEnd); |
||||
|
stream.removeListener('error', reject); |
||||
|
resolve(); |
||||
|
}, 10000); |
||||
|
|
||||
|
stream.on('data', handleChunk); |
||||
|
stream.on('end', onEnd); |
||||
|
stream.on('error', reject); |
||||
|
stream.resume(); |
||||
|
}).then((data) => { |
||||
|
if (data) assert(data.length === targetSize); |
||||
|
return data; |
||||
|
}); |
||||
|
|
||||
|
const close = () => { |
||||
|
console.log('Close', path); |
||||
|
ps.cancel(); |
||||
|
}; |
||||
|
|
||||
|
return { |
||||
|
readNextFrame, |
||||
|
close, |
||||
|
}; |
||||
|
}; |
||||
@ -0,0 +1,71 @@ |
|||||
|
const assert = require('assert'); |
||||
|
|
||||
|
const randomTransitionsSet = ['fade', 'fadegrayscale', 'directionalwarp', 'crosswarp', 'dreamyzoom', 'burn', 'crosszoom', 'simplezoom', 'linearblur', 'directional-left', 'directional-right', 'directional-up', 'directional-down']; |
||||
|
|
||||
|
function getRandomTransition() { |
||||
|
return randomTransitionsSet[Math.floor(Math.random() * randomTransitionsSet.length)]; |
||||
|
} |
||||
|
|
||||
|
|
||||
|
// https://easings.net/
|
||||
|
|
||||
|
function easeOutExpo(x) { |
||||
|
return x === 1 ? 1 : 1 - (2 ** (-10 * x)); |
||||
|
} |
||||
|
|
||||
|
function easeInOutCubic(x) { |
||||
|
return x < 0.5 ? 4 * x * x * x : 1 - ((-2 * x + 2) ** 3) / 2; |
||||
|
} |
||||
|
|
||||
|
|
||||
|
function getTransitionEasingFunction(easing, transitionName) { |
||||
|
if (easing !== null) { |
||||
|
if (easing) return { easeOutExpo }[easing]; |
||||
|
if (transitionName === 'directional') return easeOutExpo; |
||||
|
} |
||||
|
return (progress) => progress; |
||||
|
} |
||||
|
|
||||
|
function calcTransition(defaults, transition) { |
||||
|
if (transition === null) return { duration: 0 }; |
||||
|
|
||||
|
let transitionOrDefault = { |
||||
|
name: (transition && transition.name) || (defaults.transition && defaults.transition.name), |
||||
|
duration: (transition && transition.duration != null) ? transition.duration : (defaults.transition && defaults.transition.duration), |
||||
|
params: (transition && transition.params) || (defaults.transition && defaults.transition.params), |
||||
|
easing: (transition && transition.easing !== undefined) ? transition.easing : (defaults.transition && defaults.transition.easing), |
||||
|
}; |
||||
|
|
||||
|
assert(!transitionOrDefault.duration || transitionOrDefault.name, 'Please specify transition name or set duration to 0'); |
||||
|
|
||||
|
if (transitionOrDefault.name === 'random') { |
||||
|
transitionOrDefault = { easing: transitionOrDefault.easing, name: getRandomTransition(), duration: 0.5 }; |
||||
|
} |
||||
|
|
||||
|
const getTransitionByAlias = () => { |
||||
|
const aliasedTransition = { |
||||
|
'directional-left': { name: 'directional', params: { direction: [1, 0] } }, |
||||
|
'directional-right': { name: 'directional', params: { direction: [-1, 0] } }, |
||||
|
'directional-down': { name: 'directional', params: { direction: [0, 1] } }, |
||||
|
'directional-up': { name: 'directional', params: { direction: [0, -1] } }, |
||||
|
}[transitionOrDefault.name]; |
||||
|
if (aliasedTransition) return { ...transitionOrDefault, ...aliasedTransition }; |
||||
|
return transitionOrDefault; |
||||
|
}; |
||||
|
|
||||
|
const outTransition = getTransitionByAlias(); |
||||
|
|
||||
|
return { |
||||
|
name: outTransition.name, |
||||
|
duration: outTransition.duration, |
||||
|
params: outTransition.params, |
||||
|
easingFunction: getTransitionEasingFunction(outTransition.easing, outTransition.name), |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
|
||||
|
module.exports = { |
||||
|
calcTransition, |
||||
|
easeInOutCubic, |
||||
|
easeOutExpo, |
||||
|
}; |
||||
@ -0,0 +1,34 @@ |
|||||
|
const execa = require('execa'); |
||||
|
|
||||
|
function parseFps(fps) { |
||||
|
const match = typeof fps === 'string' && fps.match(/^([0-9]+)\/([0-9]+)$/); |
||||
|
if (match) { |
||||
|
const num = parseInt(match[1], 10); |
||||
|
const den = parseInt(match[2], 10); |
||||
|
if (den > 0) return num / den; |
||||
|
} |
||||
|
return undefined; |
||||
|
} |
||||
|
|
||||
|
async function readFileInfo(p) { |
||||
|
const { stdout } = await execa('ffprobe', [ |
||||
|
'-select_streams', 'v:0', '-show_entries', 'stream', '-of', 'json', p, |
||||
|
]); |
||||
|
const json = JSON.parse(stdout); |
||||
|
const stream = json.streams[0]; |
||||
|
return { |
||||
|
// numFrames: parseInt(stream.nb_frames, 10),
|
||||
|
duration: parseFloat(stream.duration, 10), |
||||
|
width: stream.width, // TODO coded_width?
|
||||
|
height: stream.height, |
||||
|
framerateStr: stream.r_frame_rate, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const multipleOf2 = (x) => (x + (x % 2)); |
||||
|
|
||||
|
module.exports = { |
||||
|
parseFps, |
||||
|
readFileInfo, |
||||
|
multipleOf2, |
||||
|
}; |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue