Browse Source

Implement audio

stateless
Mikael Finstad 6 years ago
parent
commit
512ddd6bd1
  1. 22
      README.md
  2. 135
      audio.js
  3. 14
      examples/alpha.json5
  4. 24
      examples/audio1.json5
  5. 21
      examples/audio2.json5
  6. 129
      index.js
  7. 13
      sources/frameSource.js
  8. 4
      transitions.js
  9. 45
      util.js

22
README.md

@ -4,7 +4,7 @@
This GIF / YouTube was created with this command: "editly [commonFeatures.json5](https://github.com/mifi/editly/blob/master/examples/commonFeatures.json5)". See [more examples here](https://github.com/mifi/editly/tree/master/examples#examples).
**Editly** is a tool and framework for declarative NLE (**non-linear video editing**) using Node.js and ffmpeg. Editly allows you to easily and **programmatically create a video** from a **set of clips, images and titles**, with smooth transitions and music overlaid.
**Editly** is a tool and framework for declarative NLE (**non-linear video editing**) using Node.js and ffmpeg. Editly allows you to easily and **programmatically create a video** from a **set of clips, images, audio and titles**, with smooth transitions and music overlaid.
Editly has a simple CLI for quickly assembling a video from a set of clips or images, or you can use its more flexible JavaScript API.
@ -143,7 +143,7 @@ Edit specs are JavaScript / JSON objects describing the whole edit operation wit
| `width` | `--width` | Width which all media will be converted to | `640` | |
| `height` | `--height` | Height which all media will be converted to | auto based on `width` and aspect ratio of **first video** | |
| `fps` | `--fps` | FPS which all videos will be converted to | First video FPS or `25` | |
| `audioFilePath` | `--audio-file-path` | Set an audio track to the whole output video | | |
| `audioFilePath` | `--audio-file-path` | Set an audio track for the whole video | | |
| `fast` | `--fast`, `-f` | Fast mode (low resolution and FPS, useful for getting a quick preview) | `false` | |
| `defaults.layer.fontPath` | `--font-path` | Set default font to a .ttf | System font | |
| `defaults.layer.*` | | Set any layer parameter that all layers will inherit | | |
@ -152,7 +152,7 @@ Edit specs are JavaScript / JSON objects describing the whole edit operation wit
| `defaults.transition.duration` | `--transition-duration` | Default transition duration | `0.5` | sec |
| `defaults.transition.name` | `--transition-name` | Default transition type. See **Transition types** | `random` | |
| `clips[]` | | List of clip objects that will be concatenated in sequence | | |
| `clips[].duration` | | Clip duration. See `defaults.duration` | `defaults.duration` | |
| `clips[].duration` | | Clip duration. See `defaults.duration`. If unset, the clip duration will be that of the first video layer. | `defaults.duration` | |
| `clips[].transition` | | Specify transition at the **end** of this clip. See `defaults.transition` | `defaults.transition` | |
| `clips[].layers[]` | | List of layers within the current clip that will be overlaid in their natural order (last layer on top) | | |
| `clips[].layers[].type` | | Layer type, see below | | |
@ -167,15 +167,27 @@ See [examples](https://github.com/mifi/editly/tree/master/examples) and [commonF
#### Layer type 'video'
For video layers, if parent `clip.duration` is specified, the video will be slowed/sped-up to match `clip.duration`. If `cutFrom`/`cutTo` is set, the resulting segment (`cutTo`-`cutFrom`) will be slowed/sped-up to fit `clip.duration`.
For video layers, if parent `clip.duration` is specified, the video will be slowed/sped-up to match `clip.duration`. If `cutFrom`/`cutTo` is set, the resulting segment (`cutTo`-`cutFrom`) will be slowed/sped-up to fit `clip.duration`. If the layer has audio, it will be kept (and mixed with other audio layers if present.)
| Parameter | Description | Default | |
|-|-|-|-|
| `path` | Path to video file | | |
| `resizeMode` | One of `cover`, `contain`, `stretch` | `contain` | |
| `cutFrom` | Time value to cut from | `0` | sec |
| `cutTo` | Time value to cut from | *end of video* | sec |
| `cutTo` | Time value to cut to | *end of video* | sec |
| `backgroundColor` | Background of letterboxing | `#000000` | |
| `mixVolume` | Relative volume when mixing this video's audio track with others | `1` | |
#### Layer type 'audio'
Audio layers will be mixed together. If `cutFrom`/`cutTo` is set, the resulting segment (`cutTo`-`cutFrom`) will be slowed/sped-up to fit `clip.duration`. The slow down/speed-up operation is limited to `half speed` and `100x`.
| Parameter | Description | Default | |
|-|-|-|-|
| `path` | Path to audio file | | |
| `cutFrom` | Time value to cut from | `0` | sec |
| `cutTo` | Time value to cut to | `clip.duration` | sec |
| `mixVolume` | Relative volume when mixing this audio track with others | `1` | |
#### Layer type 'image'

135
audio.js

@ -0,0 +1,135 @@
const pMap = require('p-map');
const { join, basename } = require('path');
const execa = require('execa');
const flatMap = require('lodash/flatMap');
const fs = require('fs-extra');
const { getFfmpegCommonArgs, getCutFromArgs, createConcatFile } = require('./ffmpeg');
const { readFileStreams } = require('./util');
module.exports = ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose }) => {
async function editAudio({ clips, tmpDir }) {
if (clips.length === 0) return undefined;
console.log('Extracting audio or creating silence from all clips');
const mergedAudioPath = join(tmpDir, 'audio-merged.flac');
const segments = await pMap(clips, async (clip, i) => {
const clipAudioPath = join(tmpDir, `clip${i}-audio.flac`);
const audioLayers = clip.layers.filter((layer) => ['audio', 'video'].includes(layer.type));
async function createSilence(outPath) {
if (verbose) console.log('create silence', clip.duration);
const args = [
'-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
'-sample_fmt', 's32',
'-ar', '48000',
'-t', clip.duration,
'-c:a', 'flac',
'-y',
outPath,
];
await execa(ffmpegPath, args);
}
if (audioLayers.length > 0) {
const processedAudioLayersRaw = await pMap(audioLayers, async (audioLayer, j) => {
const { path, cutFrom, audioCutTo, framePtsFactor } = audioLayer;
const streams = await readFileStreams(ffprobePath, path);
if (!streams.some((s) => s.codec_type === 'audio')) return undefined;
const layerAudioPath = join(tmpDir, `clip${i}-layer${j}-audio.flac`);
try {
let atempoFilter;
if (Math.abs(framePtsFactor - 1) > 0.01) {
if (verbose) console.log('audio framePtsFactor', framePtsFactor);
const atempo = (1 / framePtsFactor);
if (!(atempo >= 0.5 && atempo <= 100)) { // Required range by ffmpeg
console.warn(`Audio speed ${atempo} is outside accepted range, using silence (clip ${i})`);
return undefined;
}
atempoFilter = `atempo=${atempo}`;
}
const cutToArg = (audioCutTo - cutFrom) * framePtsFactor;
const args = [
...getFfmpegCommonArgs({ enableFfmpegLog }),
...getCutFromArgs({ cutFrom }),
'-i', path,
'-t', cutToArg,
'-sample_fmt', 's32',
'-ar', '48000',
'-map', 'a:0', '-c:a', 'flac',
...(atempoFilter ? ['-filter:a', atempoFilter] : []),
'-y',
layerAudioPath,
];
// console.log(args);
await execa(ffmpegPath, args);
} catch (err) {
if (verbose) console.error('Cannot extract audio from video', path, err);
// Fall back to silence
await createSilence(layerAudioPath);
}
return { layerAudioPath, audioLayer };
}, { concurrency: 4 });
const processedAudioLayers = processedAudioLayersRaw.filter((p) => p);
if (processedAudioLayers.length > 1) {
// Merge/mix all layer's audio
const weights = processedAudioLayers.map(({ audioLayer }) => (audioLayer.mixVolume != null ? audioLayer.mixVolume : 1));
const args = [
...getFfmpegCommonArgs({ enableFfmpegLog }),
...flatMap(processedAudioLayers, ({ layerAudioPath }) => ['-i', layerAudioPath]),
'-filter_complex', `amix=inputs=${processedAudioLayers.length}:duration=longest:weights=${weights.join(' ')}`,
'-c:a', 'flac',
'-y',
clipAudioPath,
];
await execa(ffmpegPath, args);
} else if (processedAudioLayers.length > 0) {
await fs.rename(processedAudioLayers[0].layerAudioPath, clipAudioPath);
} else {
await createSilence(clipAudioPath);
}
} else {
await createSilence(clipAudioPath);
}
return basename(clipAudioPath);
}, { concurrency: 4 });
const concatFilePath = join(tmpDir, 'audio-segments.txt');
console.log('Combining audio', segments, concatFilePath);
await createConcatFile(segments, concatFilePath);
const args = [
...getFfmpegCommonArgs({ enableFfmpegLog }),
'-f', 'concat', '-safe', '0',
'-i', concatFilePath,
'-c', 'flac',
'-y',
mergedAudioPath,
];
await execa(ffmpegPath, args);
// TODO don't return audio if only silence?
return mergedAudioPath;
}
return {
editAudio,
};
};

14
examples/alpha.json5

@ -0,0 +1,14 @@
{
// enableFfmpegLog: true,
outPath: './alpha.mp4',
clips: [
{ duration: 2, layers: [
{ type: 'video', path: './assets/lofoten.mp4', cutFrom: 0.4, cutTo: 2 },
{ type: 'video', path: './assets/dancer1.webm', cutFrom: 0, cutTo: 6 },
] },
{ layers: [
{ type: 'video', path: './assets/lofoten.mp4', cutFrom: 0.4, cutTo: 2 },
{ type: 'video', path: './assets/dancer1.webm' },
] },
],
}

24
examples/audio1.json5

@ -0,0 +1,24 @@
{
// enableFfmpegLog: true,
outPath: './audio1.mp4',
defaults: {
transition: null,
},
clips: [
{ duration: 0.5, layers: [{ type: 'video', path: './assets/lofoten.mp4', cutFrom: 0.4, cutTo: 2 }] },
{ layers: [
{ type: 'title', text: 'test' },
{ type: 'audio', path: './assets/High [NCS Release] - JPB (No Copyright Music)-R8ZRCXy5vhA.m4a', cutFrom: 2, cutTo: 5 }] },
{ layers: [
{ type: 'video', path: './assets/lofoten.mp4', cutFrom: 12, cutTo: 14, mixVolume: 0 },
{ type: 'audio', path: './assets/High [NCS Release] - JPB (No Copyright Music)-R8ZRCXy5vhA.m4a', mixVolume: 0.1 }] },
{ duration: 2, layers: [
{ type: 'video', path: './assets/lofoten.mp4', cutFrom: 0.4, cutTo: 2 },
{ type: 'audio', path: './assets/High [NCS Release] - JPB (No Copyright Music)-R8ZRCXy5vhA.m4a', cutFrom: 2, cutTo: 3, mixVolume: 0.5 }] },
{ duration: 1.8, layers: [{ type: 'video', path: './assets/lofoten.mp4', cutFrom: 10, cutTo: 11 }] },
],
}

21
examples/audio2.json5

@ -0,0 +1,21 @@
{
// enableFfmpegLog: true,
outPath: './audio2.mp4',
clips: [
{ duration: 0.5, layers: [{ type: 'video', path: './assets/lofoten.mp4', cutFrom: 0.4, cutTo: 2 }] },
{ layers: [
{ type: 'title', text: 'test' },
{ type: 'audio', path: './assets/High [NCS Release] - JPB (No Copyright Music)-R8ZRCXy5vhA.m4a' }] },
{ layers: [
{ type: 'video', path: './assets/lofoten.mp4', cutFrom: 12, cutTo: 14, mixVolume: 0.7 },
{ type: 'audio', path: './assets/High [NCS Release] - JPB (No Copyright Music)-R8ZRCXy5vhA.m4a', mixVolume: 0.3 }] },
{ layers: [
{ type: 'video', path: './assets/lofoten.mp4', cutFrom: 0.4, cutTo: 2 },
{ type: 'audio', path: './assets/High [NCS Release] - JPB (No Copyright Music)-R8ZRCXy5vhA.m4a' }] },
{ layers: [{ type: 'video', path: './assets/lofoten.mp4', cutFrom: 10, cutTo: 11 }] },
],
}

129
index.js

@ -1,17 +1,18 @@
const execa = require('execa');
const assert = require('assert');
const pMap = require('p-map');
const { basename, join } = require('path');
const { basename, join, dirname } = require('path');
const flatMap = require('lodash/flatMap');
const JSON5 = require('json5');
const fs = require('fs-extra');
const { parseFps, readFileInfo, multipleOf2 } = require('./util');
const { parseFps, readVideoFileInfo, readAudioFileInfo, multipleOf2 } = require('./util');
const { registerFont } = require('./sources/fabricFrameSource');
const { createFrameSource } = require('./sources/frameSource');
const { calcTransition } = require('./transitions');
const GlTransitions = require('./glTransitions');
const Audio = require('./audio');
// Cache
const loadedFonts = [];
@ -42,7 +43,8 @@ module.exports = async (config = {}) => {
const isGif = outPath.toLowerCase().endsWith('.gif');
const audioFilePath = isGif ? undefined : audioFilePathIn;
let audioFilePath;
if (!isGif) audioFilePath = audioFilePathIn;
if (audioFilePath) await assertFileExists(audioFilePath);
@ -126,66 +128,122 @@ module.exports = async (config = {}) => {
}
const clips = await pMap(clipsIn, async (clip, clipIndex) => {
const { transition: userTransition, duration: userDuration, layers } = clip;
const { transition: userTransition, duration: userClipDuration, layers } = clip;
checkTransition(userTransition);
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}`);
const userClipDurationOrDefault = userClipDuration || defaults.duration;
if (videoLayers.length === 0) assert(userClipDurationOrDefault, `Duration parameter is required for videoless clip ${clipIndex}`);
let duration = userOrDefaultDuration;
const transition = calcTransition(defaults, userTransition, clipIndex === clipsIn.length - 1);
const layersOut = flatMap(await pMap(layers, async (layerIn) => {
let layersOut = flatMap(await pMap(layers, async (layerIn) => {
const layer = { ...defaults.layer, ...layerIn };
const { type } = layer;
const { type, path } = layer;
if (type === 'video') {
const { cutFrom: cutFromIn, cutTo: cutToIn, path } = layer;
const fileInfo = await readFileInfo(ffprobePath, path);
const { duration: fileDuration, width: widthIn, height: heightIn, framerateStr, rotation } = 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);
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');
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;
}
const inputDuration = cutTo - cutFrom;
const isRotated = rotation === 90 || rotation === 270;
const width = isRotated ? heightIn : widthIn;
const height = isRotated ? widthIn : heightIn;
return { ...layer, cutFrom, cutTo, width, height, framerateStr, framePtsFactor };
// Compensate for transition duration
const audioCutTo = Math.max(cutFrom, cutTo - transition.duration);
return { ...layer, cutFrom, cutTo, audioCutTo, inputDuration, width, height, framerateStr };
}
if (type === 'audio') return layer;
return handleLayer(layer);
}, { concurrency: 1 }));
const transition = calcTransition(defaults, userTransition);
let clipDuration = userClipDurationOrDefault;
const firstVideoLayer = layersOut.find((layer) => layer.type === 'video');
if (firstVideoLayer && !userClipDuration) clipDuration = firstVideoLayer.inputDuration;
assert(clipDuration);
layersOut = await pMap(layersOut, async (layer) => {
const { type, path } = layer;
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,
duration: clipDuration,
layers: layersOut,
};
}, { concurrency: 1 });
const { editAudio } = Audio({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose });
const outDir = dirname(outPath);
const tmpDir = join(outDir, 'editly-tmp');
if (verbose) console.log({ tmpDir });
await fs.remove(tmpDir);
await fs.mkdirp(tmpDir);
if (!audioFilePath) {
audioFilePath = await editAudio({ clips, tmpDir });
}
if (verbose) console.log(JSON5.stringify(clips, null, 2));
// Try to detect parameters from first video
@ -340,7 +398,7 @@ module.exports = async (config = {}) => {
const getTransitionFromClip = () => clips[transitionFromClipId];
const getTransitionToClip = () => clips[getTransitionToClipId()];
const getSource = (clip, clipIndex) => createFrameSource({ clip, clipIndex, width, height, channels, verbose, ffmpegPath, enableFfmpegLog, framerateStr });
const getSource = (clip, clipIndex) => createFrameSource({ clip, clipIndex, width, height, channels, verbose, ffmpegPath, ffprobePath, enableFfmpegLog, framerateStr });
const getTransitionToSource = async () => (getTransitionToClip() && getSource(getTransitionToClip(), getTransitionToClipId()));
frameSource1 = await getSource(getTransitionFromClip(), transitionFromClipId);
@ -436,6 +494,7 @@ module.exports = async (config = {}) => {
} finally {
if (frameSource1) await frameSource1.close();
if (frameSource2) await frameSource2.close();
await fs.remove(tmpDir);
}
try {

13
sources/frameSource.js

@ -5,11 +5,12 @@ const { rgbaToFabricImage, customFabricFrameSource, createCustomCanvasFrameSourc
const createVideoFrameSource = require('./videoFrameSource');
const { createGlFrameSource } = require('./glFrameSource');
async function createFrameSource({ clip, clipIndex, width, height, channels, verbose, ffmpegPath, enableFfmpegLog, framerateStr }) {
async function createFrameSource({ clip, clipIndex, width, height, channels, verbose, ffmpegPath, ffprobePath, enableFfmpegLog, framerateStr }) {
const { layers, duration } = clip;
const layerFrameSources = await pMap(layers, async (layer, layerIndex) => {
const visualLayers = layers.filter((layer) => layer.type !== 'audio');
const layerFrameSources = await pMap(visualLayers, async (layer, layerIndex) => {
const { type, ...params } = layer;
console.log('createFrameSource', type, 'clip', clipIndex, 'layer', layerIndex);
@ -29,15 +30,15 @@ async function createFrameSource({ clip, clipIndex, width, height, channels, ver
const createFrameSourceFunc = frameSourceFuncs[type];
assert(createFrameSourceFunc, `Invalid type ${type}`);
return createFrameSourceFunc({ ffmpegPath, width, height, duration, channels, verbose, enableFfmpegLog, framerateStr, params });
return createFrameSourceFunc({ ffmpegPath, ffprobePath, width, height, duration, channels, verbose, enableFfmpegLog, framerateStr, params });
}, { concurrency: 1 });
async function readNextFrame(progress) {
const canvas = createFabricCanvas({ width, height });
// eslint-disable-next-line no-restricted-syntax
for (const frameSource of layerFrameSources) {
const rgba = await frameSource.readNextFrame(progress, canvas);
for (const layerFrameSource of layerFrameSources) {
const rgba = await layerFrameSource.readNextFrame(progress, canvas);
// Frame sources can either render to the provided canvas and return nothing
// OR return an raw RGBA blob which will be drawn onto the canvas
if (rgba) {

4
transitions.js

@ -26,8 +26,8 @@ function getTransitionEasingFunction(easing, transitionName) {
return (progress) => progress;
}
function calcTransition(defaults, transition) {
if (transition === null) return { duration: 0 };
function calcTransition(defaults, transition, isLastClip) {
if (transition === null || isLastClip) return { duration: 0 };
let transitionOrDefault = {
name: (transition && transition.name) || (defaults.transition && defaults.transition.name),

45
util.js

@ -1,4 +1,5 @@
const execa = require('execa');
const assert = require('assert');
function parseFps(fps) {
const match = typeof fps === 'string' && fps.match(/^([0-9]+)\/([0-9]+)$/);
@ -10,17 +11,31 @@ function parseFps(fps) {
return undefined;
}
async function readFileInfo(ffprobePath, p) {
async function readDuration(ffprobePath, p) {
const { stdout } = await execa(ffprobePath, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', p]);
const parsed = parseFloat(stdout);
assert(!Number.isNaN(parsed));
return parsed;
}
async function readFileStreams(ffprobePath, p) {
const { stdout } = await execa(ffprobePath, [
'-select_streams', 'v:0', '-show_entries', 'stream', '-of', 'json', p,
'-show_entries', 'stream', '-of', 'json', p,
]);
const json = JSON.parse(stdout);
const stream = json.streams[0];
return json.streams;
}
async function readVideoFileInfo(ffprobePath, p) {
const streams = await readFileStreams(ffprobePath, p);
const stream = streams.find((s) => s.codec_type === 'video'); // TODO
const duration = await readDuration(ffprobePath, p);
const rotation = stream.tags && stream.tags.rotate && parseInt(stream.tags.rotate, 10);
return {
// numFrames: parseInt(stream.nb_frames, 10),
duration: parseFloat(stream.duration, 10),
duration,
width: stream.width, // TODO coded_width?
height: stream.height,
framerateStr: stream.r_frame_rate,
@ -28,10 +43,30 @@ async function readFileInfo(ffprobePath, p) {
};
}
async function readAudioFileInfo(ffprobePath, p) {
const duration = await readDuration(ffprobePath, p);
return { duration };
}
function toArrayInteger(buffer) {
if (buffer.length > 0) {
const data = new Uint8ClampedArray(buffer.length);
for (let i = 0; i < buffer.length; i += 1) {
data[i] = buffer[i];
}
return data;
}
return [];
}
const multipleOf2 = (x) => (x + (x % 2));
module.exports = {
parseFps,
readFileInfo,
readVideoFileInfo,
readAudioFileInfo,
multipleOf2,
toArrayInteger,
readFileStreams,
};
Loading…
Cancel
Save