Browse Source

Implement audio cross fading

https://github.com/mifi/editly/issues/67
pull/81/head
Mikael Finstad 6 years ago
parent
commit
b49d672508
  1. 4
      README.md
  2. 66
      audio.js
  3. 31
      examples/audio-transition.json5
  4. 8
      examples/single.json5
  5. 13
      parseConfig.js
  6. 42
      transitions.js

4
README.md

@ -110,6 +110,8 @@ Edit specs are JavaScript / JSON objects describing the whole edit operation wit
transition: { transition: {
duration: 0.5, duration: 0.5,
name: 'random', name: 'random',
audioOutCurve: 'tri',
audioInCurve: 'tri',
}, },
layer: { layer: {
fontPath, fontPath,
@ -167,6 +169,8 @@ Edit specs are JavaScript / JSON objects describing the whole edit operation wit
| `defaults.transition` | | An object `{ name, duration }` describing the default transition. Set to **null** to disable transitions | | | | `defaults.transition` | | An object `{ name, duration }` describing the default transition. Set to **null** to disable transitions | | |
| `defaults.transition.duration` | `--transition-duration` | Default transition duration | `0.5` | sec | | `defaults.transition.duration` | `--transition-duration` | Default transition duration | `0.5` | sec |
| `defaults.transition.name` | `--transition-name` | Default transition type. See [Transition types](#transition-types) | `random` | | | `defaults.transition.name` | `--transition-name` | Default transition type. See [Transition types](#transition-types) | `random` | |
| `defaults.transition.audioOutCurve` | | Default [fade out curve](https://trac.ffmpeg.org/wiki/AfadeCurves) in audio cross fades | `tri` | |
| `defaults.transition.audioInCurve` | | Default [fade in curve](https://trac.ffmpeg.org/wiki/AfadeCurves) in audio cross fades | `tri` | |
| `clips[]` | | List of clip objects that will be played in sequence. Each clip can have one or more layers. | | | | `clips[]` | | List of clip objects that will be played in sequence. Each clip can have one or more layers. | | |
| `clips[].duration` | | Clip duration. See `defaults.duration`. If unset, the clip duration will be that of the **first video layer**. | `defaults.duration` | | | `clips[].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[].transition` | | Specify transition at the **end** of this clip. See `defaults.transition` | `defaults.transition` | |

66
audio.js

@ -4,7 +4,7 @@ const execa = require('execa');
const flatMap = require('lodash/flatMap'); const flatMap = require('lodash/flatMap');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { getFfmpegCommonArgs, getCutFromArgs, createConcatFile } = require('./ffmpeg');
const { getFfmpegCommonArgs, getCutFromArgs } = require('./ffmpeg');
const { readFileStreams } = require('./util'); const { readFileStreams } = require('./util');
module.exports = ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose }) => { module.exports = ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose }) => {
@ -15,21 +15,23 @@ module.exports = ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose }) => {
const mergedAudioPath = join(tmpDir, 'audio-merged.flac'); const mergedAudioPath = join(tmpDir, 'audio-merged.flac');
const segments = await pMap(clips, async (clip, i) => {
const clipsOut = await pMap(clips, async (clip, i) => {
const clipAudioPath = join(tmpDir, `clip${i}-audio.flac`); const clipAudioPath = join(tmpDir, `clip${i}-audio.flac`);
const audioLayers = clip.layers.filter(({ type, visibleFrom, visibleUntil }) => (
const { duration, layers, transition } = clip;
const audioLayers = layers.filter(({ type, visibleFrom, visibleUntil }) => (
['audio', 'video'].includes(type) ['audio', 'video'].includes(type)
// TODO We don't support audio for visibleFrom/visibleUntil layers // TODO We don't support audio for visibleFrom/visibleUntil layers
&& !visibleFrom && visibleUntil == null)); && !visibleFrom && visibleUntil == null));
async function createSilence(outPath) { async function createSilence(outPath) {
if (verbose) console.log('create silence', clip.duration);
if (verbose) console.log('create silence', duration);
const args = [ const args = [
'-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100', '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
'-sample_fmt', 's32', '-sample_fmt', 's32',
'-ar', '48000', '-ar', '48000',
'-t', clip.duration,
'-t', duration,
'-c:a', 'flac', '-c:a', 'flac',
'-y', '-y',
outPath, outPath,
@ -39,7 +41,7 @@ module.exports = ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose }) => {
if (audioLayers.length > 0) { if (audioLayers.length > 0) {
const processedAudioLayersRaw = await pMap(audioLayers, async (audioLayer, j) => { const processedAudioLayersRaw = await pMap(audioLayers, async (audioLayer, j) => {
const { path, cutFrom, audioCutTo, speedFactor } = audioLayer;
const { path, cutFrom, cutTo, speedFactor } = audioLayer;
const streams = await readFileStreams(ffprobePath, path); const streams = await readFileStreams(ffprobePath, path);
if (!streams.some((s) => s.codec_type === 'audio')) return undefined; if (!streams.some((s) => s.codec_type === 'audio')) return undefined;
@ -58,7 +60,7 @@ module.exports = ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose }) => {
atempoFilter = `atempo=${atempo}`; atempoFilter = `atempo=${atempo}`;
} }
const cutToArg = (audioCutTo - cutFrom) * speedFactor;
const cutToArg = (cutTo - cutFrom) * speedFactor;
const args = [ const args = [
...getFfmpegCommonArgs({ enableFfmpegLog }), ...getFfmpegCommonArgs({ enableFfmpegLog }),
@ -109,25 +111,41 @@ module.exports = ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose }) => {
await createSilence(clipAudioPath); await createSilence(clipAudioPath);
} }
// https://superuser.com/a/853262/658247
return resolve(clipAudioPath);
return {
path: resolve(clipAudioPath), // https://superuser.com/a/853262/658247
transition,
};
}, { concurrency: 4 }); }, { concurrency: 4 });
const concatFilePath = join(tmpDir, 'audio-segments.txt');
console.log('Combining audio', segments.map((s) => basename(s)), concatFilePath);
await createConcatFile(segments, concatFilePath);
const args = [
...getFfmpegCommonArgs({ enableFfmpegLog }),
'-f', 'concat', '-safe', '0',
'-i', concatFilePath,
'-c', 'flac',
'-y',
mergedAudioPath,
];
await execa(ffmpegPath, args);
if (clipsOut.length < 2) {
await fs.rename(clipsOut[0].path, mergedAudioPath);
} else {
console.log('Combining audio', clipsOut.map(({ path }) => basename(path)));
let inStream = '[0:a]';
const filterGraph = clipsOut.slice(0, -1).map(({ transition }, i) => {
const outStream = `[concat${i}]`;
const epsilon = 0.0001; // If duration is 0, ffmpeg seems to default to 1 sec instead, hence epsilon.
let ret = `${inStream}[${i + 1}:a]acrossfade=d=${Math.max(epsilon, transition.duration)}:c1=${transition.audioOutCurve || 'tri'}:c2=${transition.audioInCurve || 'tri'}`;
inStream = outStream;
if (i < clipsOut.length - 2) ret += outStream;
return ret;
}).join(',');
const args = [
...getFfmpegCommonArgs({ enableFfmpegLog }),
...(flatMap(clipsOut, ({ path }) => ['-i', path])),
'-filter_complex',
filterGraph,
'-c', 'flac',
'-y',
mergedAudioPath,
];
await execa(ffmpegPath, args);
}
// TODO don't return audio if only silence? // TODO don't return audio if only silence?
return mergedAudioPath; return mergedAudioPath;

31
examples/audio-transition.json5

@ -0,0 +1,31 @@
{
// enableFfmpegLog: true,
outPath: './audio-transition.mp4',
keepSourceAudio: true,
defaults: {
duration: 3,
transition: { duration: 1, name: 'directional' },
},
clips: [
{ layers: [
{ type: 'title-background', text: 'Clip 1' },
{ type: 'audio', path: './assets/sample1.m4a' }
] },
{ transition: { duration: 0.2 }, layers: [
{ type: 'title-background', text: 'Clip 2' },
{ type: 'audio', path: './assets/sample2.m4a' }
] },
{ transition: { duration: 0 }, layers: [
{ type: 'title-background', text: 'Clip 3' },
{ type: 'audio', path: './assets/sample1.m4a' }
] },
{ transition: { audioInCurve: 'exp', audioOutCurve: 'exp' }, layers: [
{ type: 'title-background', text: 'Clip 4' },
{ type: 'audio', path: './assets/sample2.m4a' }
] },
{ layers: [
{ type: 'title-background', text: 'Clip 5' },
{ type: 'audio', path: './assets/sample1.m4a' }
] },
],
}

8
examples/single.json5

@ -0,0 +1,8 @@
{
// This is a test of a single clip to make sure that it works
outPath: './single.mp4',
keepSourceAudio: true,
clips: [
{ layers: [{ type: 'video', path: './assets/lofoten.mp4', cutFrom: 0, cutTo: 2 }] },
],
}

13
parseConfig.js

@ -20,6 +20,8 @@ async function parseConfig({ defaults: defaultsIn = {}, clips, allowRemoteReques
transition: defaultsIn.transition === null ? null : { transition: defaultsIn.transition === null ? null : {
duration: 0.5, duration: 0.5,
name: 'random', name: 'random',
audioOutCurve: 'tri',
audioInCurve: 'tri',
...defaultsIn.transition, ...defaultsIn.transition,
}, },
}; };
@ -108,6 +110,7 @@ async function parseConfig({ defaults: defaultsIn = {}, clips, allowRemoteReques
let layersOut = flatMap(await pMap(layers, async (layerIn) => { let layersOut = flatMap(await pMap(layers, async (layerIn) => {
const globalLayerDefaults = defaults.layer || {}; const globalLayerDefaults = defaults.layer || {};
const thisLayerDefaults = (defaults.layerType || {})[layerIn.type]; const thisLayerDefaults = (defaults.layerType || {})[layerIn.type];
const layer = { ...globalLayerDefaults, ...thisLayerDefaults, ...layerIn }; const layer = { ...globalLayerDefaults, ...thisLayerDefaults, ...layerIn };
const { type, path } = layer; const { type, path } = layer;
@ -129,10 +132,7 @@ async function parseConfig({ defaults: defaultsIn = {}, clips, allowRemoteReques
const inputWidth = isRotated ? heightIn : widthIn; const inputWidth = isRotated ? heightIn : widthIn;
const inputHeight = isRotated ? widthIn : heightIn; const inputHeight = isRotated ? widthIn : heightIn;
// Compensate for transition duration
const audioCutTo = Math.max(cutFrom, cutTo - transition.duration);
return { ...layer, cutFrom, cutTo, audioCutTo, inputDuration, framerateStr, inputWidth, inputHeight };
return { ...layer, cutFrom, cutTo, inputDuration, framerateStr, inputWidth, inputHeight };
} }
// Audio is handled later // Audio is handled later
@ -178,10 +178,7 @@ async function parseConfig({ defaults: defaultsIn = {}, clips, allowRemoteReques
const speedFactor = clipDuration / inputDuration; const speedFactor = clipDuration / inputDuration;
// Compensate for transition duration
const audioCutTo = Math.max(cutFrom, cutTo - transition.duration);
return { ...layer, cutFrom, cutTo, audioCutTo, speedFactor };
return { ...layer, cutFrom, cutTo, speedFactor };
} }
if (layer.type === 'video') { if (layer.type === 'video') {

42
transitions.js

@ -27,37 +27,37 @@ function getTransitionEasingFunction(easing, transitionName) {
function calcTransition(defaults, transition, isLastClip) { function calcTransition(defaults, transition, isLastClip) {
if (transition === null || isLastClip) return { duration: 0 }; if (transition === null || isLastClip) return { duration: 0 };
const getTransitionDefault = (key) => (defaults.transition && defaults.transition[key]);
let transitionOrDefault = { 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),
name: (transition && transition.name) || getTransitionDefault('name'),
duration: (transition && transition.duration != null) ? transition.duration : getTransitionDefault('duration'),
params: (transition && transition.params) || getTransitionDefault('params'),
easing: (transition && transition.easing !== undefined) ? transition.easing : getTransitionDefault('easing'),
audioOutCurve: (transition && transition.audioOutCurve) || getTransitionDefault('audioOutCurve'),
audioInCurve: (transition && transition.audioInCurve) || getTransitionDefault('audioInCurve'),
}; };
assert(!transitionOrDefault.duration || transitionOrDefault.name, 'Please specify transition name or set duration to 0'); assert(!transitionOrDefault.duration || transitionOrDefault.name, 'Please specify transition name or set duration to 0');
if (transitionOrDefault.name === 'random' && transitionOrDefault.duration) { if (transitionOrDefault.name === 'random' && transitionOrDefault.duration) {
transitionOrDefault = { easing: transitionOrDefault.easing, name: getRandomTransition(), duration: transitionOrDefault.duration };
transitionOrDefault = { ...transitionOrDefault, name: getRandomTransition() };
} }
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();
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) {
transitionOrDefault = { ...transitionOrDefault, ...aliasedTransition };
}
return { return {
name: outTransition.name,
duration: outTransition.duration || 0,
params: outTransition.params,
easingFunction: getTransitionEasingFunction(outTransition.easing, outTransition.name),
...transitionOrDefault,
duration: transitionOrDefault.duration || 0,
easingFunction: getTransitionEasingFunction(transitionOrDefault.easing, transitionOrDefault.name),
}; };
} }

Loading…
Cancel
Save