diff --git a/README.md b/README.md index 648cd82..b6e2a03 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ Edit specs are JavaScript / JSON objects describing the whole edit operation wit transition: { duration: 0.5, name: 'random', + audioOutCurve: 'tri', + audioInCurve: 'tri', }, layer: { 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.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.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[].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` | | diff --git a/audio.js b/audio.js index 73f8400..ffd859d 100644 --- a/audio.js +++ b/audio.js @@ -4,7 +4,7 @@ const execa = require('execa'); const flatMap = require('lodash/flatMap'); const fs = require('fs-extra'); -const { getFfmpegCommonArgs, getCutFromArgs, createConcatFile } = require('./ffmpeg'); +const { getFfmpegCommonArgs, getCutFromArgs } = require('./ffmpeg'); const { readFileStreams } = require('./util'); module.exports = ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose }) => { @@ -15,21 +15,23 @@ module.exports = ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose }) => { 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 audioLayers = clip.layers.filter(({ type, visibleFrom, visibleUntil }) => ( + const { duration, layers, transition } = clip; + + const audioLayers = layers.filter(({ type, visibleFrom, visibleUntil }) => ( ['audio', 'video'].includes(type) // TODO We don't support audio for visibleFrom/visibleUntil layers && !visibleFrom && visibleUntil == null)); async function createSilence(outPath) { - if (verbose) console.log('create silence', clip.duration); + if (verbose) console.log('create silence', duration); const args = [ '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100', '-sample_fmt', 's32', '-ar', '48000', - '-t', clip.duration, + '-t', duration, '-c:a', 'flac', '-y', outPath, @@ -39,7 +41,7 @@ module.exports = ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose }) => { if (audioLayers.length > 0) { 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); if (!streams.some((s) => s.codec_type === 'audio')) return undefined; @@ -58,7 +60,7 @@ module.exports = ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose }) => { atempoFilter = `atempo=${atempo}`; } - const cutToArg = (audioCutTo - cutFrom) * speedFactor; + const cutToArg = (cutTo - cutFrom) * speedFactor; const args = [ ...getFfmpegCommonArgs({ enableFfmpegLog }), @@ -109,25 +111,41 @@ module.exports = ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose }) => { 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 }); - 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? return mergedAudioPath; diff --git a/examples/audio-transition.json5 b/examples/audio-transition.json5 new file mode 100644 index 0000000..f465a0d --- /dev/null +++ b/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' } + ] }, + ], +} diff --git a/examples/single.json5 b/examples/single.json5 new file mode 100644 index 0000000..740bc45 --- /dev/null +++ b/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 }] }, + ], +} diff --git a/parseConfig.js b/parseConfig.js index 6fba1dc..6fde470 100644 --- a/parseConfig.js +++ b/parseConfig.js @@ -20,6 +20,8 @@ async function parseConfig({ defaults: defaultsIn = {}, clips, allowRemoteReques transition: defaultsIn.transition === null ? null : { duration: 0.5, name: 'random', + audioOutCurve: 'tri', + audioInCurve: 'tri', ...defaultsIn.transition, }, }; @@ -108,6 +110,7 @@ async function parseConfig({ defaults: defaultsIn = {}, clips, allowRemoteReques let layersOut = flatMap(await pMap(layers, async (layerIn) => { const globalLayerDefaults = defaults.layer || {}; const thisLayerDefaults = (defaults.layerType || {})[layerIn.type]; + const layer = { ...globalLayerDefaults, ...thisLayerDefaults, ...layerIn }; const { type, path } = layer; @@ -129,10 +132,7 @@ async function parseConfig({ defaults: defaultsIn = {}, clips, allowRemoteReques const inputWidth = isRotated ? heightIn : widthIn; 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 @@ -178,10 +178,7 @@ async function parseConfig({ defaults: defaultsIn = {}, clips, allowRemoteReques 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') { diff --git a/transitions.js b/transitions.js index 9ef86a2..1a4836d 100644 --- a/transitions.js +++ b/transitions.js @@ -27,37 +27,37 @@ function getTransitionEasingFunction(easing, transitionName) { function calcTransition(defaults, transition, isLastClip) { if (transition === null || isLastClip) return { duration: 0 }; + const getTransitionDefault = (key) => (defaults.transition && defaults.transition[key]); + 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'); 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 { - 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), }; }