9 changed files with 355 additions and 54 deletions
-
22README.md
-
135audio.js
-
14examples/alpha.json5
-
24examples/audio1.json5
-
21examples/audio2.json5
-
131index.js
-
13sources/frameSource.js
-
4transitions.js
-
45util.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, |
||||
|
}; |
||||
|
}; |
||||
@ -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' }, |
||||
|
] }, |
||||
|
], |
||||
|
} |
||||
@ -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 }] }, |
||||
|
], |
||||
|
} |
||||
@ -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 }] }, |
||||
|
], |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue