#!/usr/bin/perl # Copyright (c) 2009 Jonathan Kamens . # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # To read the full GNU General Public License, see # . # To read more of the background behind this script, comment or ask # questions, or see what other people have to say, visit: # # http://blog.kamens.brookline.ma.us/2009/09/21/script-for-using-ffmpeg-to-crop-pan-and-scale-wmv-to-mov/ # The purpose of this script is to convert a WMV video into a MOV # (i.e., QuickTime) video, doing different cropping on different # segments of the video and scaling the final result. I use this # script for preparing recorded webinars for publication. The # webinars I coordinate are initially presented on a 1920x1080 screen, # but the presenter rarely uses the whole 1920x1080 area for # presentation. Rather, various windows on different parts of the # screen are used at different times. I don't want to waste bandwidth # and screen real estate in the published recording, so I want to crop # the video to whatever window is being used at any given point in # time. # This script reads a CSV file which lists time slices and the area of # the screen that is relevant for each slice. It figures out the # largest width and height used by any slice, and treats the result as # the "canvas" size to which all of the slices will be cropped. Then # it figures out the correct cropping to do on each slice based on # that final size, does the cropping and scaling into an intermediate # MPG format, and then concatenates the resulting MPGs and transcodes # into the final MOV file. # This script depends on "ffmpeg". It also does forks and execs and # opens of /dev/null to run two processing threads in parallel, so # it'll work on UNIX or Cygwin, but it probably won't work with native # Windows Perl, e.g., ActivePerl. # To use the script, give it three command-line arguments: # # 1. The name of the CSV file with the columns described below. # 2. The name of the input WMV file. # 3. The name of the output MOV file. It will be overwritten if it # exists. # The CSV file should have the following columns (with no header row): # # 1-3. The time that this slice starts at, represented as hours, minutes # and seconds, or blank to start where the last slice left off # (or, for the first slice, at the beginning of the video). # 4. The left-most pixel of the desired display area for this time # slice. # 5. Top pixel. # 6. Right-most pixel. # 7. Bottom pixel. # 8-10. (OPTIONAL) h, m, s for the end time of this time slice. If # not specified, defaults to the beginning of the next slice, # or, for the last slice, the end of the video. # # When specifying adjacent slices, always specify the time where the # first ends and the second starts as the end time of the first slice # and leave the start time of the second slice blank. # The time slices need to be in ascending order. In the coordinate # system being used, 0,0 is the upper left pixel. # # To determine the pixel locations, use ffmpeg or VLC player or some # other tool to extract a snapshot of the video at the time you want, # load the snapshot into a picture editor such as gimp, and use it to # figure out the pixel locations. # Note that there's stuff hard-coded in this script that you might # wish to change. For example, see $scaled_max_width and # $scaled_max_height, which is currently set to always scale to fit # within a 1024x768 window (if you want to send me patches to make # these settable on the command line, feel free!), and the encoder # arguments specified at the beginning of the &scale subroutine. # Some interesting things I learned about ffmpeg while figuring out # how to do all of this: # * You might think that multiple "-i" options on the ffmpeg command # line would allow you to concatenate multiple input videos serially # into your output, but that's not actually what it does. Rather, # the streams in the specified input files are encoded *in parallel* # into the output file. There are undoubtedly reasons why one might # want multiple audio or multiple video streams in a file, but # clearly that's not right for this application. # # * The right way to use ffmpeg to concatenate multiple input videos # serially into an output video is to feed all of the videos to # ffmpeg's stdin, and to specify "-f -" on its command line. # HOWEVER, this only works for certain video formats. I know for a # fact that it works for MPEG, so that's what I use here as my # intermediate video format; it may work for others as well. It # does *not* work for QuickTime videos. # # * The "-sameq" argument to ffmpeg is very nifty. It tells ffmpeg to # match the output video quality to the input's video quality, so # you get virtually lossless encoding without having to try to guess # what the input quality is and specify the parameters necessary to # match it in the output. I use this argument to indicate the video # quality for the intermediate encoding (i.e., from WMV to cropped # and scaled MPG), because the default quality settings for MPG # output are quite lossy, at least for webinar-like videos. # # * The libx264 video codec is by far the best one to use if you're # encoding screen recordings such as webinars. As you can see below # in the encoder settings I use in &scale, you can get away with # very low framerates and bitrates with libx264 and still end up # with a perfectly tolerable video, if you don't mind the mouse # jumping around a little. # Things to watch out for: # * Ffmpeg appears to be unable to read some WMV files, e.g., those # produced by the free Windows Media File Editor distributed by # Microsoft. # # * Ffmpeg doesn't always get slice durations exactly right, i.e., # sometimes it stops a few seconds past where you told it to stop. # This seems to happen much more frequently within the first minute # or so of the input video, although I'm not certain about that; if # I'm right, then you should avoid making adjacent slices within # that first minute, or your video or audio could end up being a bit # choppy. If anybody knows how to make ffmpeg obey the "-t" option # more precisely, I'm all ears. # # * Leave a couple pixels of extra space around the borders of your # slices, since they may need to be adjusted by a pixel or two to # make ffmpeg happy. # # * I don't see any reason why this wouldn't work for input video # types other than WMV; that just happens to be the type I'm working # with, so I coded the script to accept that particular type to # ensure that I specified command-line arguments in the right order. # If you want to modify it to accept other input file types that # you've tried and that work just fine, feel free to send me # patches. # A BRIEF EXAMPLE # Suppose you have a one-minute WMV file recorded at 1920x1080 # resolution. You want to throw away the first 20 seconds, display # the upper left quadrant of the screen for the second 20 seconds, and # display the lower right quadrant on the screen for the last 20 # seconds. Here's the CSV file you would use to accomplish this: # # 0,0,20,0,0,959,539,0,0,40 # ,,,950,540,1919,1079,,, # # The first line says to produce a 20-second slice, starting at 20 # seconds into the video and ending at 40 seconds into the video, # whose upper left corner is 0,0 and bottom right corner is 959,539. # # The second line says to produce a slide starting where the previous # slice left off, ending at the end of the video, with the upper left # corner 950,540 and the lower right corner 1919,1079. # # See, that's not so hard, is it? use strict; use warnings; use English '-no_match_vars'; use File::Temp 'tempfile'; use IO::File; use Text::CSV; my $scaled_max_width = 1024; my $scaled_max_height = 768; my $csv = &get_arg("csv"); my $wmv = &get_arg("wmv"); my $mov = &get_arg("mov", 1); my(@transitions, $wmv_width, $wmv_height, $crop_width, $crop_height, $scaled_width, $scaled_height, $crop_pid, $scale_pid); &parse_csv; &measure_wmv; &calculate_cropping; &calculate_scaled; &crop; &scale; waitpid $scale_pid, 0; die "ffmpeg scaler failed\n" if ($?); waitpid $crop_pid, 0; die "ffmpeg cropper failed\n" if ($?); warn "Done.\n"; sub scale { my(@cmd) = ('ffmpeg', '-y', '-i', '-', '-ar', 22050, '-ab', '24k', '-r', 1, '-b', 3000, '-vcodec', 'libx264', $mov); warn("@cmd\n"); $SYSTEM_FD_MAX = fileno(READFROMCROP); $scale_pid = fork(); die "fork: $!\n" if (! defined $scale_pid); if (! $scale_pid) { open(STDIN, "<&", *READFROMCROP) or die "open(<&READFROMCROP): $!\n"; exec(@cmd) or die "exec(@cmd): $!\n"; } } sub crop { pipe(READFROMCROP, WRITETOSCALE) or die "pipe: $!\n"; $crop_pid = fork(); die "fork: $!" if (! defined($crop_pid)); if ($crop_pid) { close(WRITETOSCALE); return; } close(READFROMCROP); open(STDOUT, ">&", *WRITETOSCALE) or die "open(STDOUT, >&WRITETOSCALE): $!\n"; open(STDERRCOPY, ">&", *STDERR) or die "open(STDERRCOPY, >&STDERR): $!\n"; open(STDIN, "<", "/dev/null") or die "open(STDIN, {start}) { push(@cmd, '-ss', $transition->{start}); } if ($next || $transition->{end}) { push(@cmd, '-t', ($transition->{end} || $next->{start}) - &z($transition->{start})); } foreach my $crop (qw(left right top bottom)) { if ($transition->{"crop$crop"}) { push(@cmd, "-crop$crop", $transition->{"crop$crop"}); } } push(@cmd, '-sameq', '-s', "${scaled_width}x$scaled_height"); push(@cmd, '-f', 'mpeg', '-'); warn "@cmd\n"; my $fh = tempfile() or die; open(STDERR, ">&", $fh) or die "open(STDERR, >&temp file): $!\n"; my $ret = system(@cmd); open(STDERR, ">&", *STDERRCOPY) or die; die "@cmd failed\n" if ($ret); seek($fh, 0, 0) or die; while (<$fh>) { if (/^frame=.* time=([\d.]+)/) { $time = $1; } } close $fh; if (! $time) { warn "cropper could not determine slice duration\n"; } else { warn "slice actual duration was $time seconds\n"; } $transition->{end} = ($transition->{start} || 0) + $time; if ($next && ! defined $next->{start}) { $next->{start} = $transition->{end}; } } exit; } sub calculate_scaled { my $x_ratio = $scaled_max_width / $crop_width; my $y_ratio = $scaled_max_height / $crop_height; $x_ratio = 1 if ($x_ratio > 1); $y_ratio = 1 if ($y_ratio > 1); if ($x_ratio > $y_ratio) { $x_ratio = $y_ratio; } else { $y_ratio = $x_ratio; } # $x_ratio and $y_ratio are equal now, but we continue to treat them # separately, in case at some point in the future we decide to allow not # preserving the aspect ratio. $scaled_width = int($crop_width * $x_ratio); $scaled_height = int($crop_height * $y_ratio); warn "Scaling to ${scaled_width}x$scaled_height\n"; } sub calculate_cropping { $crop_width = $crop_height = 0; map { $crop_width = $_->{width} if ($_->{width} > $crop_width); $crop_height = $_->{height} if ($_->{height} > $crop_height); } @transitions; warn "Cropping size is ${crop_width}x${crop_height}\n"; my $n = 1; foreach my $transition (@transitions) { my $extra_width = $crop_width - $transition->{width}; my $left = $transition->{left} - 1 - int($extra_width/2); my $right = $wmv_width - $transition->{right} - int($extra_width/2); while ($wmv_width - $left - $right < $crop_width) { # Lost one in rounding. $right--; } if ($left < 0) { $right += $left; $left = 0; } if ($right < 0) { $left += $right; $right = 0; } # Left and top crop sizes must be a multiple of two? At # least, that's what ffmpeg said. It's possible that *all* # the crop sizes need to be a multiple of two, in which case # I'll have to code this differently, but for now I'm just # accounting for the only errors that ffmpeg has actually # given me to date. if ($left % 2) { $left--; $right++; } my $extra_height = $crop_height - $transition->{height}; my $top = $transition->{top} - 1 - int($extra_height/2); my $bottom = $wmv_height - $transition->{bottom} - int($extra_height/2); while ($wmv_height - $top - $bottom < $crop_height) { # Lost one in rounding. $bottom--; } if ($top < 0) { $bottom += $top; $top = 0; } if ($bottom < 0) { $top += $bottom; $bottom = 0; } # Left and top crop sizes must be a multiple of two? At # least, that's what ffmpeg said. It's possible that *all* # the crop sizes need to be a multiple of two, in which case # I'll have to code this differently, but for now I'm just # accounting for the only errors that ffmpeg has actually # given me to date. if ($top % 2) { $top--; $bottom++; } $transition->{cropleft} = $left; $transition->{cropright} = $right; $transition->{croptop} = $top; $transition->{cropbottom} = $bottom; warn "Cropping $n: left=$left, right=$right, top=$top, buttom=$bottom\n"; } continue { $n++; } } sub measure_wmv { local($_); my $pid = open(FFMPEG, '-|'); die "fork: $!\n" if (! defined $pid); if (! $pid) { open(STDERR, ">&STDOUT") or die "open(STDERR, >&STDOUT): $!"; exec('ffmpeg', '-i', $wmv, '-t', 0, '-f', 'mpeg', '-') or die "exec(ffmpeg): $!"; } while () { if (/Video:.*, (\d+)x(\d+),/) { $wmv_width = $1; $wmv_height = $2; last; } } close(FFMPEG); die "Could not determine dimensions of $wmv\n" if (! ($wmv_width and $wmv_height)); warn "Input wmv is ${wmv_width}x$wmv_height\n"; } sub parse_csv { my $csv_parser = Text::CSV->new(); my $io = new IO::File or die; $io->open($csv, "<") or die; while (my $row = $csv_parser->getline($io)) { if (@$row != 7 && @$row != 10) { die("Row $. in $csv has ", scalar @$row, " columns instead of 7 or 10\n"); } map { die("Column ", $_+1, " ($row->[$_]) on line $. of $csv is empty\n") if (! defined($row->[$_])); } (3..6); map { die("Column ", $_+1, " ($row->[$_]) on line $. of $csv is not numeric\n") if ($row->[$_] !~ /^\d*$/); } 0..@$row-1; my($start, $end); if ($row->[0] ne '' || $row->[1] ne '' || $row->[2] ne '') { $start = &z($row->[2]) + (&z($row->[1]) + &z($row->[0]) * 60) * 60; } if (@$row == 10 and ($row->[7] ne '' || $row->[8] ne '' || $row->[9] ne '')) { $end = &z($row->[9]) + (&z($row->[8]) + &z($row->[7]) * 60) * 60; } if (@transitions) { if (! defined($start)) { if (! defined($transitions[-1]->{end})) { die("Can't determine start time for line $. in $csv\n"); } } elsif (defined($transitions[-1]->{start}) and ($start <= $transitions[-1]->{start})) { die("Transitions must be in increasing chronological order ", "(line $. in $csv is earlier than the precedind line)\n"); } } my $transition; $transition->{start} = $start; $transition->{end} = $end; $transition->{left} = $row->[3]; $transition->{top} = $row->[4]; $transition->{right} = $row->[5]; $transition->{bottom} = $row->[6]; $transition->{width} = $transition->{right} - $transition->{left} + 1; $transition->{height} = $transition->{bottom} - $transition->{top} + 1; push(@transitions, $transition); } $io->close or die; } sub get_arg { my($type, $missing_ok) = @_; die "Missing $type argument\n" if (! @ARGV); my $arg = shift @ARGV; die "Argument \"$arg\" should be .$type file\n" if ($arg !~ /\.$type$/); die "$arg does not exist\n" if (! ($missing_ok or -f $arg)); return $arg; } sub z { my($v) = @_; (! defined($v) or $v eq '') ? 0 : $v; } # $Id: wmv-to-panned-mov.pl,v 1.4 2009/09/21 17:43:40 jik Exp $