Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"@types/ndarray": "^1.0.14",
"canvas": "^2.11.2",
"compare-versions": "^6.1.0",
"execa": "^6.1.0",
"execa": "^8.0.1",
"fabric": "^6.5.4",
"file-type": "^20.0.0",
"file-url": "^4.0.0",
Expand Down
34 changes: 14 additions & 20 deletions src/audio.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import pMap from 'p-map';
import { join, basename, resolve } from 'path';
import { execa } from 'execa';
import { flatMap } from 'lodash-es';

import { getFfmpegCommonArgs, getCutFromArgs } from './ffmpeg.js';
import { readFileStreams } from './util.js';
import { getCutFromArgs, ffmpeg } from './ffmpeg.js';
import { readFileStreams } from './ffmpeg.js';

import type { AudioLayer, AudioNormalizationOptions, AudioTrack, Clip, Config, Transition, VideoLayer } from './types.js'

export type AudioOptions = {
ffmpegPath: string;
ffprobePath: string;
enableFfmpegLog: boolean;
verbose: boolean;
tmpDir: string;
}
Expand All @@ -22,7 +18,7 @@ export type EditAudioOptions = Pick<Config, "keepSourceAudio" | "clips" | "clips

type LayerWithAudio = (AudioLayer | VideoLayer) & { speedFactor: number };

export default ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose, tmpDir }: AudioOptions) => {
export default ({ verbose, tmpDir }: AudioOptions) => {
async function createMixedAudioClips({ clips, keepSourceAudio }: { clips: Clip[], keepSourceAudio?: boolean }) {
return pMap(clips, async (clip, i) => {
const { duration, layers, transition } = clip;
Expand All @@ -33,6 +29,7 @@ export default ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose, tmpDir }: A
async function createSilence() {
if (verbose) console.log('create silence', duration);
const args = [
'-nostdin',
'-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
'-sample_fmt', 's32',
'-ar', '48000',
Expand All @@ -41,7 +38,7 @@ export default ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose, tmpDir }: A
'-y',
clipAudioPath,
];
await execa(ffmpegPath, args);
await ffmpeg(args);

return { silent: true, clipAudioPath };
}
Expand All @@ -60,7 +57,7 @@ export default ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose, tmpDir }: A
const processedAudioLayersRaw = await pMap(audioLayers, async (audioLayer, j) => {
const { path, cutFrom, cutTo, speedFactor } = audioLayer;

const streams = await readFileStreams(ffprobePath, path);
const streams = await readFileStreams(path);
if (!streams.some((s) => s.codec_type === 'audio')) return undefined;

const layerAudioPath = join(tmpDir, `clip${i}-layer${j}-audio.flac`);
Expand All @@ -80,7 +77,7 @@ export default ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose, tmpDir }: A
const cutToArg = (cutTo! - cutFrom!) * speedFactor;

const args = [
...getFfmpegCommonArgs({ enableFfmpegLog }),
'-nostdin',
...getCutFromArgs({ cutFrom }),
'-i', path,
'-t', cutToArg!.toString(),
Expand All @@ -92,8 +89,7 @@ export default ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose, tmpDir }: A
layerAudioPath,
];

// console.log(args);
await execa(ffmpegPath, args);
await ffmpeg(args);

return [
layerAudioPath,
Expand All @@ -115,15 +111,15 @@ export default ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose, tmpDir }: A
// Merge/mix all layers' audio
const weights = processedAudioLayers.map(([, { mixVolume }]) => mixVolume ?? 1);
const args = [
...getFfmpegCommonArgs({ enableFfmpegLog }),
'-nostdin',
...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);
await ffmpeg(args);
return { clipAudioPath, silent: false };
}

Expand Down Expand Up @@ -160,15 +156,15 @@ export default ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose, tmpDir }: A
}).join(',');

const args = [
...getFfmpegCommonArgs({ enableFfmpegLog }),
'-nostdin',
...(flatMap(clipAudio, ({ path }) => ['-i', path])),
'-filter_complex',
filterGraph,
'-c', 'flac',
'-y',
outPath,
];
await execa(ffmpegPath, args);
await ffmpeg(args);

return outPath;
}
Expand Down Expand Up @@ -198,7 +194,7 @@ export default ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose, tmpDir }: A
const mixedAudioPath = join(tmpDir, 'audio-mixed.flac');

const args = [
...getFfmpegCommonArgs({ enableFfmpegLog }),
'-nostdin',
...(flatMap(streams, ({ path, loop }) => ([
'-stream_loop', (loop || 0).toString(),
'-i', path,
Expand All @@ -210,9 +206,7 @@ export default ({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose, tmpDir }: A
mixedAudioPath,
];

if (verbose) console.log(args.join(' '));

await execa(ffmpegPath, args);
await ffmpeg(args);

return mixedAudioPath;
}
Expand Down
100 changes: 97 additions & 3 deletions src/ffmpeg.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
import fsExtra from 'fs-extra';
import { execa } from 'execa';
import { execa, type Options } from 'execa';
import assert from 'assert';
import { compareVersions } from 'compare-versions';

export function getFfmpegCommonArgs({ enableFfmpegLog }: { enableFfmpegLog?: boolean }) {
return enableFfmpegLog ? [] : ['-hide_banner', '-loglevel', 'error'];
export type Stream = {
codec_type: string;
codec_name: string;
r_frame_rate: string;
width?: number;
height?: number;
tags?: {
rotate: string;
};
side_data_list?: {
rotation: string;
}[];
};

export type FfmpegConfig = {
ffmpegPath: string;
ffprobePath: string;
enableFfmpegLog?: boolean;
}

const config: FfmpegConfig = {
ffmpegPath: 'ffmpeg',
ffprobePath: 'ffprobe',
enableFfmpegLog: false,
}

export function getFfmpegCommonArgs() {
return [
'-hide_banner',
...(config.enableFfmpegLog ? [] : ['-loglevel', 'error']),
];
}

export function getCutFromArgs({ cutFrom }: { cutFrom?: number }) {
Expand Down Expand Up @@ -35,3 +64,68 @@ export async function testFf(exePath: string, name: string) {
console.error(`WARNING: ${name}:`, err);
}
}

export async function configureFf(params: Partial<FfmpegConfig>) {
Object.assign(config, params);
await testFf(config.ffmpegPath, 'ffmpeg');
await testFf(config.ffprobePath, 'ffprobe');
}

export function ffmpeg(args: string[], options?: Options<null>) {
if (config.enableFfmpegLog) console.log(`$ ${config.ffmpegPath} ${args.join(' ')}`);
return execa(config.ffmpegPath, [...getFfmpegCommonArgs(), ...args], options);
}

export function ffprobe(args: string[]) {
return execa(config.ffprobePath, args);
}

export function parseFps(fps?: string) {
const match = typeof fps === 'string' && fps.match(/^([0-9]+)\/([0-9]+)$/);
if (match) {
const num = parseInt(match[1], 10);
const den = parseInt(match[2], 10);
if (den > 0) return num / den;
}
return undefined;
}

export async function readDuration(p: string) {
const { stdout } = await ffprobe(['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', p]);
const parsed = parseFloat(stdout);
assert(!Number.isNaN(parsed));
return parsed;
}

export async function readFileStreams(p: string) {
const { stdout } = await ffprobe(['-show_entries', 'stream', '-of', 'json', p]);
return JSON.parse(stdout).streams as Stream[];
}


export async function readVideoFileInfo(p: string) {
const streams = await readFileStreams(p);
const stream = streams.find((s) => s.codec_type === 'video'); // TODO

if (!stream) {
throw new Error(`Could not find a video stream in ${p}`);
}

const duration = await readDuration(p);

let rotation = parseInt(stream.tags?.rotate ?? '', 10);

// If we can't find rotation, try side_data_list
if (Number.isNaN(rotation) && stream.side_data_list?.[0]?.rotation) {
rotation = parseInt(stream.side_data_list[0].rotation, 10);
}

return {
// numFrames: parseInt(stream.nb_frames, 10),
duration,
width: stream.width, // TODO coded_width?
height: stream.height,
framerateStr: stream.r_frame_rate,
rotation: !Number.isNaN(rotation) ? rotation : undefined,
};
}
32 changes: 15 additions & 17 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { execa, ExecaChildProcess } from 'execa';
import { ExecaChildProcess } from 'execa';
import assert from 'assert';
import { join, dirname } from 'path';
import JSON5 from 'json5';
import fsExtra from 'fs-extra';
import { nanoid } from 'nanoid';

import { testFf } from './ffmpeg.js';
import { parseFps, multipleOf2, assertFileValid, checkTransition } from './util.js';
import { configureFf, ffmpeg, parseFps } from './ffmpeg.js';
import { multipleOf2, assertFileValid, checkTransition } from './util.js';
import { createFabricCanvas, rgbaToFabricImage } from './sources/fabric.js';
import { createFrameSource } from './sources/frameSource.js';
import parseConfig, { ProcessedClip } from './parseConfig.js';
Expand All @@ -26,7 +26,6 @@ export type * from './types.js';
async function Editly(config: Config): Promise<void> {
const {
// Testing options:
enableFfmpegLog = false,
verbose = false,
logTimes = false,
keepTmp = false,
Expand All @@ -49,12 +48,12 @@ async function Editly(config: Config): Promise<void> {
outputVolume,
customOutputArgs,

enableFfmpegLog = verbose,
ffmpegPath = 'ffmpeg',
ffprobePath = 'ffprobe',
} = config;

await testFf(ffmpegPath, 'ffmpeg');
await testFf(ffprobePath, 'ffprobe');
await configureFf({ ffmpegPath, ffprobePath, enableFfmpegLog });

const isGif = outPath.toLowerCase().endsWith('.gif');

Expand All @@ -67,15 +66,15 @@ async function Editly(config: Config): Promise<void> {
assert(outPath, 'Please provide an output path');
assert(clipsIn.length > 0, 'Please provide at least 1 clip');

const { clips, arbitraryAudio } = await parseConfig({ defaults, clips: clipsIn, arbitraryAudio: arbitraryAudioIn, backgroundAudioPath, backgroundAudioVolume, loopAudio, allowRemoteRequests, ffprobePath });
const { clips, arbitraryAudio } = await parseConfig({ defaults, clips: clipsIn, arbitraryAudio: arbitraryAudioIn, backgroundAudioPath, backgroundAudioVolume, loopAudio, allowRemoteRequests });
if (verbose) console.log('Calculated', JSON5.stringify({ clips, arbitraryAudio }, null, 2));

const outDir = dirname(outPath);
const tmpDir = join(outDir, `editly-tmp-${nanoid()}`);
if (verbose) console.log({ tmpDir });
await fsExtra.mkdirp(tmpDir);

const { editAudio } = Audio({ ffmpegPath, ffprobePath, enableFfmpegLog, verbose, tmpDir });
const { editAudio } = Audio({ verbose, tmpDir });

const audioFilePath = !isGif ? await editAudio({ keepSourceAudio, arbitraryAudio, clipsAudioVolume, clips, audioNorm, outputVolume }) : undefined;

Expand Down Expand Up @@ -212,8 +211,6 @@ async function Editly(config: Config): Promise<void> {

function startFfmpegWriterProcess() {
const args = [
...(enableFfmpegLog ? [] : ['-hide_banner', '-loglevel', 'error']),

'-f', 'rawvideo',
'-vcodec', 'rawvideo',
'-pix_fmt', 'rgba',
Expand All @@ -230,8 +227,7 @@ async function Editly(config: Config): Promise<void> {

'-y', outPath,
];
if (verbose) console.log('ffmpeg', args.join(' '));
return execa(ffmpegPath, args, { encoding: null, buffer: false, stdin: 'pipe', stdout: process.stdout, stderr: process.stderr });
return ffmpeg(args, { encoding: null, buffer: false, stdin: 'pipe', stdout: process.stdout, stderr: process.stderr });
}

let outProcess: ExecaChildProcess<Buffer<ArrayBufferLike>> | undefined = undefined;
Expand All @@ -252,7 +248,7 @@ async function Editly(config: Config): Promise<void> {
const getTransitionFromClip = () => clips[transitionFromClipId];
const getTransitionToClip = () => clips[getTransitionToClipId()];

const getSource = async (clip: ProcessedClip, clipIndex: number) => createFrameSource({ clip, clipIndex, width, height, channels, verbose, logTimes, ffmpegPath, ffprobePath, enableFfmpegLog, framerateStr });
const getSource = async (clip: ProcessedClip, clipIndex: number) => createFrameSource({ clip, clipIndex, width, height, channels, verbose, logTimes, framerateStr });
const getTransitionFromSource = async () => getSource(getTransitionFromClip(), transitionFromClipId);
const getTransitionToSource = async () => (getTransitionToClip() && getSource(getTransitionToClip(), getTransitionToClipId()));

Expand Down Expand Up @@ -423,14 +419,16 @@ export async function renderSingleFrame(config: RenderSingleFrameConfig): Promis

verbose,
logTimes,
enableFfmpegLog,
allowRemoteRequests,
ffprobePath = 'ffprobe',
ffmpegPath = 'ffmpeg',
ffprobePath = 'ffprobe',
enableFfmpegLog,
outPath = `${Math.floor(Math.random() * 1e12)}.png`,
} = config;

const { clips } = await parseConfig({ defaults, clips: clipsIn, arbitraryAudio: [], allowRemoteRequests, ffprobePath });
configureFf({ ffmpegPath, ffprobePath, enableFfmpegLog });

const { clips } = await parseConfig({ defaults, clips: clipsIn, arbitraryAudio: [], allowRemoteRequests });
let clipStartTime = 0;
const clip = clips.find((c) => {
if (clipStartTime <= time && clipStartTime + c.duration > time) return true;
Expand All @@ -439,7 +437,7 @@ export async function renderSingleFrame(config: RenderSingleFrameConfig): Promis
});
assert(clip, 'No clip found at requested time');
const clipIndex = clips.indexOf(clip);
const frameSource = await createFrameSource({ clip, clipIndex, width, height, channels, verbose, logTimes, ffmpegPath, ffprobePath, enableFfmpegLog, framerateStr: '1' });
const frameSource = await createFrameSource({ clip, clipIndex, width, height, channels, verbose, logTimes, framerateStr: '1' });
const rgba = await frameSource.readNextFrame({ time: time - clipStartTime });

// TODO converting rgba to png can be done more easily?
Expand Down
Loading
Loading