Automate-Animate-FFmpeg

 view release on metacpan or  search on metacpan

lib/Automate/Animate/FFmpeg.pm  view on Meta::CPAN

package Automate::Animate::FFmpeg;

use 5.006;
use strict;
use warnings;

our $VERSION = '0.13';

use utf8; # filenames can be in utf8

binmode STDERR, ':encoding(UTF-8)';
binmode STDOUT, ':encoding(UTF-8)';

use File::Temp;
use File::Find::Rule;
use IPC::Run;
use Text::ParseWords;
use Encode;
use Data::Roundtrip qw/perl2dump no-unicode-escape-permanently/;
use Cwd::utf8 qw/abs_path cwd/;

sub	new {
	my $class = $_[0];
	my $params = $_[1];

	my $parent = ( caller(1) )[3] || "N/A";
	my $whoami = ( caller(0) )[3];

	my $self = {
		'input-images' => [],
		'output-filename' => undef,
		'verbosity' => 0,
		# params to ffmpeg must be given as an arrayref
		# these are our standard params, they are read only and must
		# be supplied here as an ARRAYref
		'ffmpeg-standard-params' => ['-c', 'copy', '-c:v', 'copy', '-c:a', 'copy', '-q:a', '0', '-q:v', '1'],
		# extra params to ffmpeg can be specified by the caller
		# we store this as an ARRAYref, each option and value is an item of its own
		'ffmpeg-extra-params' => [],
		# the duration of each frame in seconds (fractional supported)
		# or <=0 for using FFmpeg's defaults
		'frame-duration' => 0,
		# this will be modified during perl Makefile.PL
		'ffmpeg-executable' => '/usr/local/bin/ffmpeg', # specify fullpath if not in path
		# end this will be modified during perl Makefile.PL
	};
	bless $self, $class;

	my $ak;

	# sort out the easy params
	my $verbos = 0;
	$ak = 'verbosity';
	if( exists($params->{$ak}) && defined($verbos=$params->{$ak}) ){ $self->verbosity($verbos) }

	for $ak (qw/
		output-filename
		frame-duration
	/){
		if( exists($params->{$ak}) && defined($params->{$ak}) ){
			$self->{$ak} = $params->{$ak};
			if( $verbos > 1 ){ print "${whoami} (via $parent), line ".__LINE__." : parameter '$ak' set to : ".$params->{$ak}."\n"; }
		}
	}

	# specify input images as scalar, arrayref or hashref (values)
	$ak = 'input-images';
	if( exists($params->{$ak}) && defined($params->{$ak})
		&& scalar($params->{$ak})
	){
		$self->input_images($params->{$ak});
	}

	# input image filenames are read from specified file (one filename per line)
	$ak = 'input-images-from-file';
	if( exists($params->{$ak}) && defined($params->{$ak})
		&& scalar($params->{$ak})
	){
		if( ! $self->input_file_with_images($params->{$ak}) ){ print STDERR perl2dump($params->{$ak})."${whoami} (via $parent), line ".__LINE__." : error, failed to load input images from file containing their pathnames: '".$params->{$ak}."'.\n"; return un...
	}

	# input images can be specified via a pattern and a search dir
	# like : 'input-pattern' => ['*.png', '/x/y/searchdir']
	$ak = 'input-pattern';
	if( exists($params->{$ak}) && defined($params->{$ak}) ){
		if( ref($params->{$ak})ne'ARRAY' ){ print STDERR perl2dump($params->{$ak})."${whoami} (via $parent), line ".__LINE__." : error, the argument to '$ak' must be an ARRAYref of 1 or 2 items: the pattern and optionally the search dir. See above for what...
		if( ! $self->input_pattern($params->{$ak}) ){ print STDERR perl2dump($params->{$ak})."${whoami} (via $parent), line ".__LINE__." : error, failed to find input files based on the above pattern and search dir.\n"; return undef }
	}
	# or via multiple patterns (an ARRAY of ARRAY patterns, as above)
	$ak = 'input-patterns';
	if( exists($params->{$ak}) && defined($params->{$ak}) ){
		if( ref($params->{$ak})ne'ARRAY' ){ print STDERR perl2dump($params->{$ak})."${whoami} (via $parent), line ".__LINE__." : error, the argument to '$ak' must be an ARRAYref of one or more ARRAYrefs each of 1 or 2 items: the pattern and optionally the ...
		if( ! $self->input_patterns($params->{$ak}) ){ print STDERR perl2dump($params->{$ak})."${whoami} (via $parent), line ".__LINE__." : error, failed to find input files based on the above pattern and search dir.\n"; return undef }
	}

	# specify output filename
	$ak = 'output-filename';
	if( exists($params->{$ak}) && defined($params->{$ak}) ){
		$self->output_filename($params->{$ak});
		if( $verbos > 1 ){ print "${whoami} (via $parent), line ".__LINE__." : parameter '$ak' set to : ".$params->{$ak}."\n"; }
	}
	# any extra ffmpeg params?
	# these are cmdline options to FFmpeg and must be
	# passed as an ARRAY, each flag, option and parameter is
	# a single array item, for example
	# ['-i', 'inputfile', '-o', 'out', '-p', '1', '2']
	# note that above -p is used as in -p 1 2
	$ak = 'ffmpeg-extra-params';
	if( exists($params->{$ak}) && defined($params->{$ak}) ){
		if( ! defined($self->ffmpeg_extra_params($params->{$ak})) ){ print STDERR perl2dump($params->{$ak})."${whoami} (via $parent), line ".__LINE__." : error, failed to parse/verify the above params to ffmpeg via '--ffmpeg-extra-params'.\n"; return undef...
		if( $verbos > 1 ){ print "${whoami} (via $parent), line ".__LINE__." : parameter '$ak' set to : ".$params->{$ak}."\n"; }
	}

	return $self
}
# it spawns ffmpeg as external command via IPC::Run::run(@cmd)
# requires that at least 1 input image was specified before.
# returns 0 on failure, 1 on success
sub	make_animation {
	my $self = $_[0];
	my $parent = ( caller(1) )[3] || "N/A";
	my $whoami = ( caller(0) )[3];
	my $verbos = $self->verbosity();

	my $cmdret = $self->_build_ffmpeg_cmdline();
	if( ! defined $cmdret ){ print STDERR "${whoami} (via $parent), line ".__LINE__." : error, failed to build ffmpeg command line, call to ".'_build_ffmpeg_cmdline()'." has failed.\n"; return 0 }
	my $cmdline = $cmdret->{'cmdline'};
	my $tmpfile = $cmdret->{'tmpfile'};
	if( $verbos > 0 ){ print "${whoami} (via $parent), line ".__LINE__." : executing system command:\n".join(' ',@$cmdline)."\n" }
	my ($in, $out, $err);
	my $ret = IPC::Run::run($cmdline, \$in, \$out, \$err);
	if( ! $ret ){ 
		print STDERR "$out\n$err\n${whoami} (via $parent), line ".__LINE__." : error, executing this command has failed (the list of input files is in '$tmpfile', you may delete it when you are done):\n  ".join(' ', @$cmdline)."\n";
		return 0
	}
	if( $verbos > 0 ){
		if( $verbos > 1 ){ print $out."\n" }
		print "${whoami} (via $parent), line ".__LINE__." : done, success. Output animation of ".$self->num_input_images()." input images is in '".$self->output_filename()."'.\n"
	}
	unlink($tmpfile);

	return 1;
}
sub	_build_ffmpeg_cmdline {
	my $self = $_[0];
	my $parent = ( caller(1) )[3] || "N/A";
	my $whoami = ( caller(0) )[3];
	my $verbos = $self->verbosity();

	if( $self->num_input_images() == 0 ){ print STDERR "${whoami} (via $parent), line ".__LINE__." : error, no input images in store.\n"; return undef }
	if( ! defined $self->output_filename() ){ print STDERR "${whoami} (via $parent), line ".__LINE__." : error, no output filename specified.\n"; return undef }

	my $execu = $self->ffmpeg_executable();
	if( ! defined $execu ){ print STDERR "$whoami() (via $parent), line ".__LINE__." : error, the path to the ffmpeg executable is undefined. That means that the external, 3rd-party dependency 'ffmpeg' was not located during installation. First you need...

	# get a tmp file - only drawback is it is in current working dir
	# but on the other hand we do not want caller to delete a directory!
	my ($fh, $tmpfile) = File::Temp::tempfile('XXXXXXXXXXXX', SUFFIX => '.txt');
	my $duration_str = $self->frame_duration() > 0 ? "duration ".$self->frame_duration()."\n" : "";
	binmode $fh, ':encoding(UTF-8)';
	print $fh "file '".$_."'\n"
			  .${duration_str}
	for @{$self->{'input-images'}};
	close $fh;

	my @cmdline = (
		$execu,
		# the extra params to FFmpeg is just a command-line args style
		@{ $self->ffmpeg_extra_params() },
		# and the concats etc.
		'-f', 'concat',
		'-y',
		# this is about accepting relative filepaths to images
		'-safe', '0',
		# all images filepathas are in this file (one in each line)
		'-i', $tmpfile,
		@{ $self->ffmpeg_standard_params() },
		$self->output_filename()
	);
	return {
		'cmdline' => \@cmdline,
		# this can be unlinked by caller if needed
		'tmpfile' => $tmpfile
	}
}
# set the executable only via the constructor
sub	ffmpeg_executable { return $_[0]->{'ffmpeg-executable'} }
sub	verbosity {
	my $self = $_[0];
	my $m = $_[1];
	return $self->{'verbosity'} unless defined $m;
	$self->{'verbosity'} = $m;
	return $m
}
sub	frame_duration {
	my $self = $_[0];
	my $m = $_[1];
	return $self->{'frame-duration'} unless defined $m;
	$self->{'frame-duration'} = $m;
	return $m
}

# note: when setting an output filename, make sure you
# specify the extension and it does make sense to FFmpeg (e.g. mp4)
sub	output_filename {
	my $self = $_[0];
	my $outfile = $_[1];
	return $self->{'output-filename'} unless defined $outfile;
	$self->{'output-filename'} = $outfile;
	return $outfile
}
sub	_cmdline2argsarray {
	my $m = $_[0];
	if( ref($m) eq 'ARRAY' ){
		return [ @$m ]
	} elsif( ref($m) eq '' ){
		return Text::ParseWords::shellwords($m)
	} elsif( ref($m) eq 'HASH' ){
		return [ %$m ];
	}
	print STDERR "_cmdline2argsarray() : error, an ARRAYref/HASHref/String-scalar containing command-line arguments was expected, not ".ref($m)."\n";
	return undef
}
sub	ffmpeg_extra_params {
	my $self = $_[0];
	my $m = $_[1];
	return $self->{'ffmpeg-extra-params'} unless defined $m;
	my $ret = _cmdline2argsarray($self->{'ffmpeg-extra-params'});
	if( ! defined $ret ){ print STDERR perl2dump($ret)."ffmpeg_extra_params() : error, failed to pass above arguments.\n"; return undef }
	$self->{'ffmpeg-extra-params'} = $ret;
	return $ret
}
sub	ffmpeg_standard_params { return $_[0]->{'ffmpeg-standard-params'} }
sub	num_input_images { return scalar @{$_[0]->{'input-images'}} }
# specify a text file which holds image filenames, one per line to be added
# hash-comments are understood, empty/only-space lines are removed
# returns 1 on success, 0 on failure
sub	input_file_with_images {
	my ($self, $infile) = @_;
	my $parent = ( caller(1) )[3] || "N/A";
	my $whoami = ( caller(0) )[3];
	my $verbos = $self->verbosity();
	if( ! defined $infile ){ print STDERR "${whoami} (via $parent), line ".__LINE__." : error, an input filename of input image filenames is expected.\n"; return 0 }
	my $fh;
	if( ! open($fh, '<:encoding(UTF-8)', $infile) ){ print STDERR "${whoami} (via $parent), line ".__LINE__." : error, could not open input file '$infile' for reading, $!\n"; return 0 }
	while( <$fh> ){
		chomp;
		s/#.*$//;
		s/^\s*$//;
		$self->input_images($_) unless /^\s*$/;
	} close $fh;
	return 1
}
sub	clear_input_images { $#{ $_[0]->{'input-images'} } = -1 }
# Add using a single pattern/searchdir
# add image files via a pattern and an input dir, e.g. '*.png', '/x/y/z/'
# make sure that the order you expect is what you get during the pattern materialisation
# the search dir is optional, default is Cwd::cwd
sub	input_pattern {
	my ($self, $params) = @_;
	my $parent = ( caller(1) )[3] || "N/A";
	my $whoami = ( caller(0) )[3];
	my $verbos = $self->verbosity();
	my ($_pattern, $indir) = @$params;
	my $indir_need_encode_utf8 = 0;
	if( ! defined $indir ){
		$indir = _my_cwd();
		if( $verbos > 0 ){ print STDERR "${whoami} (via $parent), line ".__LINE__." : warning, no search dir was specified and using current dir '$indir'.\n" }
	} else { $indir_need_encode_utf8 = 1 }
	my $pattern;
	# allows for /pattern/modifiers
	if( $_pattern =~ m!^regex\(/(.*?)/(.*?)\)$! ){
		# see https://www.perlmonks.org/?node_id=1210675
		my $pa = $1; my $mo = $2;
		if( $mo!~/^[msixpodualn]+$/ ){ print STDERR "${whoami} (via $parent), line ".__LINE__." : error, illegal modifiers ($mo) to the specified regex detected.\n"; return 0 }
		# the modifiers are entered as (?...) before the regex pattern
		$pattern = qr/(?${mo})${pa}/;
	} else { $pattern = $_pattern }
	if( $verbos > 1 ){ print "${whoami} (via $parent), line ".__LINE__." : searching under dir '$indir' with pattern '".$pattern."' ...\n" }

	if( ! defined $self->input_images([
		# this little piglet does not support unicode
		# or, rather, readdir() needs some patching
		# additionally, it fails in M$ as the unicoded
		# filenames get doubly encoded, let's see if this will fix it:
		($^O eq 'MSWin32')
			?
			    File::Find::Rule
				->file()
				->name($pattern)
				->in($indir)
			:
			    map { Encode::decode_utf8($_) }
			    File::Find::Rule
				->file()
				->name($pattern)
				->in(Encode::encode_utf8($indir))
	]) ){ print STDERR "${whoami} (via $parent), line ".__LINE__." : error, call to input_images() has failed.\n"; return 0 }

	return 1 # success
}
# This adds many patterns:
# the input is an ARRAY of 1-or-2-item arrays
# each subarray must consist of a pattern and optionally a search dir (else current dir will be used)
sub	input_patterns {
	my ($self, $specs) = @_;
	my $parent = ( caller(1) )[3] || "N/A";
	my $whoami = ( caller(0) )[3];
	my $verbos = $self->verbosity();
	for my $as (@$specs){
		if( (scalar(@$as)==0)
		 || (scalar(@$as)>2)
		){ print STDERR perl2dump($as)."${whoami} (via $parent), line ".__LINE__." : error, the spec must contain at least a pattern and optionally a search-dir as an array, see above.\n"; return 0; }
		if( ! $self->input_pattern($as) ){ print STDERR "${whoami} (via $parent), line ".__LINE__." : error, call to input_pattern() has failed for this spec: @$as\n"; return 0 }
	}
	return 1 # success
}
# if no parameter is specified then it returns the current list of input images as an arrayref
# Otherwise:
# specify one or more input filenames (of images) via a single scalar, an arrayref or
# a hashref whose values are image filenames, to convert them into video
# in this case returns undef on failure or the current, updated list of input images on success
sub	input_images {
	my ($self, $m) = @_;
	if( ! defined $m ){ return $self->{'input-images'} }
	my $parent = ( caller(1) )[3] || "N/A";
	my $whoami = ( caller(0) )[3];
	my $verbos = $self->verbosity();
	if( $verbos > 0 ){
		if( $verbos > 1 ){ print STDOUT perl2dump($m)."${whoami} (via $parent), line ".__LINE__." : called with input images as shown above ...\n" }
		else { print STDOUT "${whoami} (via $parent), line ".__LINE__." : called ...\n" }
	}
	# NOTE: Cwd::abs_path() messes up on unicode filenames and requires Encode::decode_utf8()
	# but there is also Cwd::utf8 to consider sometime...
	my $rf = ref $m;
	if( $rf eq 'ARRAY' ){
		for my $af (@$m){
			if( ! -e $af ){ print STDERR "${whoami} (via $parent), line ".__LINE__." : warning/1, input image '$af' does not exist on disk and will be ignored.\n"; next }
			push @{$self->{'input-images'}}, _my_abs_path($af);
		}
	} elsif( $rf eq 'HASH' ){
		for my $af (values %$m){
			if( ! -e $af ){ print STDERR "${whoami} (via $parent), line ".__LINE__." : warning/2, input image '$af' does not exist on disk and will be ignored.\n"; next }
			push @{$self->{'input-images'}}, _my_abs_path($af);
		}		
	} elsif( $rf eq '' ){
		if( ! -e $m ){ print STDERR "${whoami} (via $parent), line ".__LINE__." : warning/3, input image '$m' does not exist on disk and will be ignored.\n"; }
		else { push @{$self->{'input-images'}}, _my_abs_path($m) }
	} else { print STDERR "${whoami} (via $parent), line ".__LINE__." : error, input can be an arrayref of input image filenames, a hashref whose values are filenames or a single filename in the form of a scalar."; return undef }
	if( $verbos > 1 ){ print STDOUT perl2dump($self->{'input-images'})."${whoami} (via $parent), line ".__LINE__." : called and added some images, above is a list of all input images to create the animation.\n" }
	return $self->{'input-images'}
}

sub _my_cwd {
	return cwd()
}
# NOTE: unicode filenames may not be canonicalised
# e.g. iota-including-accent and iota with separate accent.
# the OS will not care but if you do comparisons you will fail
# So, consider canonicalising the filenames if you are doing comparison
# e.g. in the tests
# see https://perlmonks.org/?node_id=11156629
# Also, Cwd needs utf8 decode but Cwd::utf8 doesn't
# And File::Find::Rule needs also utf8 decoce unless it is chained to abs_path
sub _my_abs_path {
	#return Encode::decode_utf8(abs_path($_[0])) # for plain Cwd
	return abs_path($_[0]) # for Cwd::utf8
}

# that's the end, pod now starts

=pod

=encoding UTF-8

=head1 NAME

Automate::Animate::FFmpeg - Create animation from a sequence of images using FFmpeg

=head1 VERSION

Version 0.13


=head1 SYNOPSIS

This module creates an animation from a sequence of input



( run in 1.561 second using v1.01-cache-2.11-cpan-f56aa216473 )