#!/usr/bin/perl -w # Copyright © 2011-2013 Jamie Zawinski # # Permission to use, copy, modify, distribute, and sell this software and its # documentation for any purpose is hereby granted without fee, provided that # the above copyright notice appear in all copies and that both that # copyright notice and this permission notice appear in supporting # documentation. No representations are made about the suitability of this # software for any purpose. It is provided "as is" without express or # implied warranty. # # Normalize the volume on a video file. The audio track will be extracted, # adjusted and *re-encoded*. The video will be unaltered. # # With the --video argument, the video will be re-encoded as well. # # With --identify, it doesn't write any files, just tells you how much # the volume should be changed. # # iTunes automatically adjusts playback volume for audio files, but does # nothing for the volume of video files. You can adjust video files # manually using the "Volume Adjustment" slider under the "Get Info" # dialog, but that only lets you increase the volume by 6dB. Some # videos require more adjustment than that. # # Requires "ffmpeg" (for encoding) and "lame" (for volume analysis). # The --video argument requires "HandBrakeCLI", since I trust that # more than ffmpeg for video encoding. This requires a version of # HandBrakeCLI newer than 0.9.5, which (at the time of this writing) # means a nightly build from "https://build.handbrake.fr/job/Mac/". # # ffmpeg is only required at all because HandBrakeCLI doesn't have # an option to add a time offset between the audio and video tracks, # which is sometimes necessary when converting Quicktime files. # # # BE CAREFUL WITH THIS! Check the results. It fucks up all the time. # # # Sometimes what I get out of ffmpeg has the wrong aspect ratio, or # won't play -- apparently it screws up "-vcodec copy". If that happens, # try re-encoding the video too, with the --video option. # # Also, on some files, audio and video get out of sync. I don't know # a fix for that. # # Created: 12-Jul-2011. require 5; use diagnostics; use strict; my $progname = $0; $progname =~ s@.*/@@g; my $version = q{ $Revision: 1.23 $ }; $version =~ s/^[^\d]+([\d.]+).*/$1/; my $verbose = 0; my $def_video_quality = "17.75"; my @unlink = (); END { system ("rm", "-f", @unlink) if @unlink; } sub safe_system(@) { my @cmd = @_; system (@cmd); my $exit_value = $? >> 8; my $signal_num = $? & 127; my $dumped_core = $? & 128; error ("$cmd[0]: core dumped!") if ($dumped_core); error ("$cmd[0]: signal $signal_num!") if ($signal_num); error ("$cmd[0]: exited with $exit_value!") if ($exit_value); } # returns the full path of the named program, or undef. # sub which($) { my ($prog) = @_; foreach (split (/:/, $ENV{PATH})) { if (-x "$_/$prog") { return $prog; } } return undef; } sub video_volume_adjustment($) { my ($file) = @_; error ("$file does not exist") unless -f $file; my $tmp = sprintf ("%s/vrpg.%08x.wav", ($ENV{TMPDIR} ? $ENV{TMPDIR} : "/tmp"), rand(0xFFFFFFFF)); push @unlink, $tmp; unlink $tmp; # Find the duration of the video. my $ff = $file; $ff =~ s@([^-_.,a-zA-Z\d/])@\\$1@gs; $_ = `ffmpeg -i $ff 2>&1`; my ($hh, $mm, $ss) = m@^ +Duration: (\d\d):(\d\d):(\d\d)\.@mi; error ("unable to find duration of $file") unless defined($ss); my $dur = ($hh * 60 * 60) + ($mm * 60) + $ss; my $start = int ($dur * 0.25); my $end = int ($dur * 0.75); my $dur2 = $end - $start; if ($dur2 < 45) { $start = 0; $end = $dur; } print STDERR "$file: duration = $dur; checking $start - $end\n" if ($verbose); # Extract the audio track from the video file as a 16 bit 44.1kHz WAV. my @cmd = ("ffmpeg", "-i", $file, "-vn", "-acodec", "pcm_s16le", "-ar", "44100", "-ac", "2", "-ss", $start, "-t", $dur2, $tmp); print STDERR "$progname: exec: " . join(' ', @cmd) . "\n" if ($verbose); open (my $o, '>&', \*STDERR); # close stderr since ffmpeg won't shut up open (STDERR, '>', '/dev/null') unless ($verbose > 1); open (STDIN, '<', '/dev/null'); # ...and.... WTF. safe_system (@cmd); open (STDERR, '>&', $o); # Use lame to determine the volume adjustment for that WAV file in dB. error ("no audio extracted") if (-z $tmp); my $cmd = "lame --replaygain-accurate -f '$tmp' /dev/null"; print STDERR "$progname: exec: $cmd\n" if ($verbose); my $out = `$cmd 2>&1`; unlink $tmp; my ($db) = ($out =~ m/^ReplayGain: \s+ ( [-+]?[\d.]+ ) \s* dB $/mx); error ("no ReplayGain from: $cmd") unless defined($db); print STDERR "$progname: ReplayGain: $db dB\n" if ($verbose); # The minimum volume difference the human ear can perceive is about 1.0 dB. # So if the dB change is that small, do nothing. # $db = 0 if ($db > -1.0 && $db < 1.0); # Convert the dB value to a ratio (1.0 for no change, 0.5 for half as loud). # # For relative volume x, dB value is 20*log[10](x) # if y=log[b](x) then x=b^y # db=20*log[10](x) # and x=10^(db/20) return ((10 ** ($db/20)), $db); } sub video_first_p($) { my ($file) = @_; my $ff = $file; $ff =~ s@([^-_.,a-zA-Z\d/])@\\$1@gs; my $cmd = "ffmpeg -i $ff"; print STDERR "$progname: exec: $cmd\n" if ($verbose); my $result = `$cmd`; my $first_p = ($result =~ m@^ *Stream \#0.0.*: Video:@s); print STDERR "$progname: $file: stream order: " . ($first_p ? "Video, Audio" : "Audio, Video") . "\n" if ($verbose); return $first_p; } sub unique_file_name($) { my ($file) = @_; my $i = 2; my $ofile = $file; while (-f $file) { # Find a non-conflicting output file name $file = $ofile; $file =~ s@(\.[^.]+)$@ $i$1@s; $i++; } return $file; } sub trash($) { my ($file) = @_; my $dir = $ENV{HOME} . "/.Trash"; # #### Kludge for how I deal with torrents. $dir = ' Watched' if (-d ' Watched'); error ("$dir does not exist") unless (-d $dir); my $f2 = $file; $f2 =~ s@^.*/@@s; $f2 = unique_file_name ("$dir/$f2"); rename ($file, $f2) || error ("mv \"$file\" \"$f2\": $!"); if ($verbose > 2) { print STDERR "$progname: mv \"$file\" \"$f2\"\n"; } elsif ($verbose > 1) { print STDERR "$progname: trashed $file\n"; } } sub video_reencode($$$$$$$) { my ($in, $out, $video_p, $force_p, $trash_p, $video_quality, $max_width) = @_; my ($vol, $db) = video_volume_adjustment ($in); my $ovol = sprintf("%0.2f", $vol); my $oin = $in; if (!defined($out)) { print STDERR "$progname: $in: needs adjustment of $ovol ($db dB)\n"; return; } # ffmpeg's -vol argument treats 256 as the "1.0" point, so scale it. $vol = int ($vol * 256); if ($vol == 256) { print STDERR "$progname: $in: no volume change\n"; return unless $force_p; } # If a MOV file has been edited, the first frame to be played may not # be the first frame in the file. For example, if you've trimmed N # seconds off the beginning of a MOV, there may actually be N seconds # of video in the file that should not be played! Left to its own # devices, HandBrakeCLI will copy these "hidden" frames, so we have to # tell it how much time to skip. # my $start_clip = 0; { my $ff = $in; $ff =~ s@([^-_.,a-zA-Z\d/])@\\$1@gs; $_ = `ffmpeg -i $ff 2>&1`; ($start_clip) = m@^ +Duration: [\d:.]+, start: (-?\d+\.\d+),@mi; error ("unable to find start offset of $in") unless defined($start_clip); $start_clip = $start_clip + 0; $start_clip = -$start_clip unless ($start_clip == 0); print STDERR "$progname: starting offset: $start_clip\n" if ($verbose); } my @cmd; if ($video_p) { # Re-encode both audio and video, using HandBrakeCLI. my $tmp2 = sprintf ("%s/vrpg.%08x.mp4", ($ENV{TMPDIR} ? $ENV{TMPDIR} : "/tmp"), rand(0xFFFFFFFF)); push @unlink, $tmp2; unlink $tmp2; # These are the settings that I've found to be both high quality and # maximally compatible. If you have differing opinions on this, let # me know! $video_quality = $def_video_quality unless ($video_quality); @cmd = ("HandBrakeCLI", "--encoder", "x264", "--encopts", "cabac=0:ref=2:me=umh:bframes=0:weightp=0:" . "8x8dct=0:trellis=0:subme=6", "--quality", $video_quality, "--aencoder", "faac", "--ab", "160,160", "--arate", "Auto,Auto", "--previews", "30", "--custom-anamorphic", # "--deinterlace", "slow", # "--decomb", # "--crop", "0:0:0:0", # To fix 16:9 videos that really should be 4:3: # "--pixel-aspect", "3:4", "--maxWidth", "1080", "--modulus", "16"); push @cmd, ("--gain", $db) if $db; push @cmd, ("--start-at", "duration:$start_clip") if $start_clip; push @cmd, ("--maxWidth", $max_width) if $max_width; push @cmd, ("--input", $in, "--output", $out); } else { # Re-encode audio only, using ffmpeg. # # HandBrakeCLI can re-code video but copy the audio # track unchanged; but it can't copy video unchanged # while re-coding audio. So we have to use ffmpeg, # which can. # WHAT THE FUCKING FUCK. # # I want to clip $start_clip seconds from the front of the audio stream, # and offset the audio and video by $start_clip seconds to put them # back in sync. # # I cannot comprehend how -ss and -itsoffset actually work. # # Is -ss allowed to be negative? Is -itsoffset? # # I can't even tell whether -ss comes before the corresponding -i or # after. # # I think I've tried every possible permutation, and it just doesn't # make any god damned sense. # error ("video has a clipped start; I don't know how to re-sync audio") if ($start_clip); @cmd = ("ffmpeg", (!$start_clip ? ("-i", $in) : (video_first_p ($in) ? ("-i", $in, "-i", $in, "-ss", $start_clip, "-map", "0:0", "-map", "1:1", ) : ("-i", $in, "-ss", $start_clip, "-i", $in, "-map", "0:1", "-map", "1:0", ))), "-vcodec", "copy", # do not re-encode video "-vol", $vol, # adjust volume "-acodec", "libfaac", # encode audio as AAC "-ab", "160k", # 160kbps "-map_metadata", "0:0", # retain original file's metadata $out); } return if ($trash_p && -f $out); print STDERR "$progname: exec: " . join(' ', @cmd) . "\n" if ($verbose); # Close STDERR since neither ffmpeg nor HandBrakeCLI will shut the fuck up. # open (my $o, '>&', \*STDERR); open (my $o2,'>&', \*STDOUT); open (STDERR, '>', '/dev/null') unless ($verbose > 1); open (STDOUT, '>', '/dev/null') unless ($verbose > 1); unlink $out; safe_system (@cmd); open (STDERR, '>&', $o); open (STDOUT, '>&', $o2); my $old_size = (stat($oin))[7]; my $new_size = (stat($out))[7]; if (! $old_size) { unlink $out; error ("$in: vanished??"); } my $pct = int ($new_size * 100 / $old_size); my $max_change = 30; my $old_size2 = sprintf("%.1f MB", ($old_size / 1024 / 1024)); my $new_size2 = sprintf("%.1f MB", ($new_size / 1024 / 1024)); print STDERR "$progname: wrote $out\n"; if ($pct > 100 + $max_change) { print STDERR "$progname: WARNING: expanded by more than $max_change\%.\n"; } elsif ($pct < 100 - $max_change) { print STDERR "$progname: WARNING: shrunk by more than $max_change\%.\n"; } elsif ($new_size > (112 * 1024 * 1024)) { print STDERR "$progname: WARNING: still pretty big!\n"; } print STDERR "$progname: $db dB, $old_size2 -> $new_size2, $pct%.\n"; print STDERR "\n"; trash ($in) if ($trash_p); } # Whether another instance of this script is already running, # as the current user. # sub running_p() { my $pids = `ps uxwww`; foreach my $p (split(/\n/, $pids)) { my ($pid) = ($p =~ m/^\s*[^\s]+\s+(\d+)\b/si); next unless ($p =~ m/\Q$progname/s); next if ($p =~ m/ xargs /s); next if ($pid == $$); print STDERR "$progname: already running in pid $pid ($p)\n" if ($verbose); return 1; } return 0; } # GAAAAH everything is terrible BEGIN { open (REAL_STDERR, '>&', \*STDERR); } sub error($) { my ($err) = @_; print REAL_STDERR "$progname: $err\n"; exit 1; } sub usage() { print STDERR "usage: $progname [--verbose] [--video] [--identify]\n" . "\t\t [--quality $def_video_quality] [--width 1280] [--trash] files ...\n"; exit 1; } sub main() { my @files = (); my $video_p = 0; my $force_p = 0; my $trash_p = 0; my $identify_p = 0; my $video_quality = 0; my $max_width = 0; while ($#ARGV >= 0) { $_ = shift @ARGV; if (m/^--?verbose$/) { $verbose++; } elsif (m/^-v+$/) { $verbose += length($_)-1; } elsif (m/^--?video$/) { $video_p = 1; } elsif (m/^--?force$/) { $force_p = 1; } elsif (m/^--?trash$/) { $trash_p = 1; } elsif (m/^--?identify$/) { $identify_p = 1; } elsif (m/^--?q(ual(ity)?)?$/) { $video_quality = shift @ARGV; } elsif (m/^--?w(idth)?$/) { $max_width = shift @ARGV; } elsif (m/^-./) { usage; } else { push @files, $_; } } usage unless ($#files >= 0); my @cmds = ("ffmpeg", "lame"); push @cmds, "HandBrakeCLI" if ($video_p); foreach my $c (@cmds) { which ($c) || error ("$c not found on \$PATH"); } foreach my $file (@files) { my $out = $file; $out =~ s@\.mov$@.mp4@si; # HandBrakeCLI can't write MOV. $out =~ s@\.[^./]+$@.mp4@si; # Eh, let's always write MP4. my $oout = $out; $out = unique_file_name ($out) unless ($trash_p); $out = undef if $identify_p; next if ($trash_p && -f $out); exit(0) if ($trash_p && running_p()); binmode (STDERR, ':utf8'); video_reencode ($file, $out, $video_p, $force_p, $trash_p, $video_quality, $max_width); } } main(); exit 0;