Last active
August 3, 2024 12:41
-
-
Save postspectacular/8bb6893f9288b65f6488779a0e538d46 to your computer and use it in GitHub Desktop.
TypeScript & ffmpeg-based crossfading & looping slideshow generator. Supports GIF or MP4 output. Configure options at the end of the file, then run via https://bun.sh
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Z4, type Stringer } from "@thi.ng/strings"; | |
import { | |
comp, | |
iterator, | |
map, | |
mapcat, | |
partition, | |
range, | |
wrapSides, | |
} from "@thi.ng/transducers"; | |
import { execFileSync } from "node:child_process"; | |
interface FfmpegXfadeOpts { | |
/** format function for input file paths, receives index in [start,start+num) range */ | |
input: Stringer<number>; | |
/** output file path (file will be overridden!) */ | |
output: string; | |
/** start index of the image sequence */ | |
start: number; | |
/** number of frames in the image sequence */ | |
num: number; | |
/** frame duration (incl. xfade) */ | |
duration: number; | |
/** xfade duration (must be less than `duration`) */ | |
fade: number; | |
/** output framerate */ | |
fps: number; | |
/** extra ffmpeg args (appended after filter def */ | |
extra?: string; | |
} | |
// helper to assemble all ffmpeg arguments | |
// (see invocation at the end of this file) | |
const defArgs = (opts: FfmpegXfadeOpts) => [ | |
...defInputs(opts), | |
"-filter_complex", | |
defXfade(opts), | |
"-map", | |
"[v]", | |
"-r", | |
`${opts.fps}`, | |
...(opts.extra?.split(" ") || []), | |
"-y", | |
opts.output, | |
]; | |
// helper to define input file args | |
const defInputs = ({ input, num, duration, start }: FfmpegXfadeOpts) => | |
mapcat( | |
(i) => ["-loop", "1", "-t", `${duration}`, "-i", input(i + start)], | |
range(num) | |
); | |
// full crossfade filter definition helper | |
const defXfade = (opts: FfmpegXfadeOpts) => | |
[ | |
...xfadeParts(opts), | |
...xfadeFinalize(opts.num), | |
`[bg${opts.num - 1}][f${opts.num - 1}]overlay,${xfadePost( | |
opts.output.endsWith(".gif") | |
)}`, | |
].join(";"); | |
// helper to construct the ffmpeg complex filter terms | |
const xfadeParts = ({ num, duration, fade }: FfmpegXfadeOpts) => | |
iterator( | |
comp( | |
partition(2, 1), | |
map( | |
([a, b]) => | |
`[${b}]fade=d=${fade}:t=in:alpha=1,setpts=PTS-STARTPTS+${ | |
a * duration | |
}/TB[f${a}]` | |
) | |
), | |
wrapSides(range(num), 0, 1) | |
); | |
// helper to assemble the ffmpeg complex filter stages into a pipeline | |
const xfadeFinalize = (num: number) => | |
map( | |
(i) => `[${i ? "bg" : ""}${i}][f${i}]overlay[bg${i + 1}]`, | |
range(num - 1) | |
); | |
// post-processing filter steps (pending chosen output format) | |
const xfadePost = (gif?: boolean) => | |
gif | |
? "split [a][b];[a] palettegen [p];[b][p] paletteuse[v]" | |
: "format=yuv420p[v]"; | |
// finally, invoke ffmpeg with constructed args... | |
execFileSync( | |
"ffmpeg", | |
defArgs({ | |
input: (i) => `frame-${Z4(i)}.png`, | |
output: "out-xfade.mp4", | |
start: 1, | |
num: 10, | |
fps: 25, | |
duration: 1, | |
fade: 0.5, | |
extra: "-c:v libx264 -preset slow -crf 23", | |
}) | |
); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment