From 512ddd6bd16753c80fed65525a5be51606f351b0 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Mon, 17 Aug 2020 23:27:23 +0200 Subject: [PATCH] Implement audio --- README.md | 22 +++++-- audio.js | 135 +++++++++++++++++++++++++++++++++++++++++ examples/alpha.json5 | 14 +++++ examples/audio1.json5 | 24 ++++++++ examples/audio2.json5 | 21 +++++++ index.js | 131 ++++++++++++++++++++++++++++----------- sources/frameSource.js | 13 ++-- transitions.js | 4 +- util.js | 45 ++++++++++++-- 9 files changed, 355 insertions(+), 54 deletions(-) create mode 100644 audio.js create mode 100644 examples/alpha.json5 create mode 100644 examples/audio1.json5 create mode 100644 examples/audio2.json5 diff --git a/README.md b/README.md index 1c3fabd..9a43b94 100644 --- a/README.md +++ b/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' diff --git a/audio.js b/audio.js new file mode 100644 index 0000000..941e6bb --- /dev/null +++ b/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, + }; +}; diff --git a/examples/alpha.json5 b/examples/alpha.json5 new file mode 100644 index 0000000..0598a31 --- /dev/null +++ b/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' }, + ] }, + ], +} diff --git a/examples/audio1.json5 b/examples/audio1.json5 new file mode 100644 index 0000000..d38d974 --- /dev/null +++ b/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 }] }, + ], +} diff --git a/examples/audio2.json5 b/examples/audio2.json5 new file mode 100644 index 0000000..fdd0078 --- /dev/null +++ b/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 }] }, + ], +} diff --git a/index.js b/index.js index 6996afc..225551e 100644 --- a/index.js +++ b/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); - assert(cutFrom < cutTo, 'cutFrom must be lower than cutTo'); - - trimmedSourceDuration = cutTo - cutFrom; - } + 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 user specified duration, means that should be the output duration - let framePtsFactor; - if (userDuration) { - duration = userDuration; - framePtsFactor = userDuration / trimmedSourceDuration; - } else { - duration = trimmedSourceDuration; - framePtsFactor = 1; - } + if (!cutTo) cutTo = fileDuration; + cutTo = Math.max(cutTo, cutFrom); + cutTo = Math.min(cutTo, fileDuration); + assert(cutFrom < cutTo, 'cutFrom must be lower than cutTo'); + + 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 { diff --git a/sources/frameSource.js b/sources/frameSource.js index 1050659..16cdcab 100644 --- a/sources/frameSource.js +++ b/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) { diff --git a/transitions.js b/transitions.js index b32ff1c..45363ee 100644 --- a/transitions.js +++ b/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), diff --git a/util.js b/util.js index 2116187..c4db287 100644 --- a/util.js +++ b/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, };