Skip to content

Instantly share code, notes, and snippets.

@postspectacular
Last active August 3, 2024 12:41
Show Gist options
  • Save postspectacular/8bb6893f9288b65f6488779a0e538d46 to your computer and use it in GitHub Desktop.
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
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