#!/bin/bash # # $Rev$ $Date$ # # vcs # Video Contact Sheet *NIX: Generates contact sheets (previews) of videos # # Copyright (C) 2007 Toni Corvera # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library 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 # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # # Author: Toni Corvera # declare -r VERSION="1.0.2b" # # History: # # 1.0.2b: (2007-04-14) # * Licensed under LGPL (was unlicensed before) # * Renamed variables and constants to me more congruent # * Added DEFAULT_COLS # * BUGFIX: Fixed program signature # * Streamlined error codes # * Added cleanup on failure and on delayed cleanup on success # * Changed default signature background to SlateGray (blue-ish gray) # # 1.0.1a: (2007-04-13) # * Print output filename # * Added manual mode (all timestamps provided by user) # * More flexible timestamp format (now e.g. 1h5 is allowed (means 1h 5secs) # * BUGFIX: Discard repeated timestamps # * Added "set -e". TODO: Add more verbose error messages when called # programs fail. # * Added basic support for a user configuration file. # # 1.0a: (2007-04-10) # * First release keeping track of history # * Put vcs' url in the signature # * Use system username in signature # * Added --shoehorn (you get the idea, right?) to feed extra commands to # the cappers. Lowelevel and not intended to be used anyway :P # * When just a vidcap is requested, take it from the middle of the video # * Added -H|--height # # 0.99.1a: Interim version, renamed to 1.0a # # 0.99a: # * Added shadows # * More colourful headers # * Easier change of colours/fonts # # 0.5a: * First usable version # 0.1: * First proof of concept set -e # Configuration file, please, use this file to modify the behaviour of the # script. Using this allows overriding some variables (see below) # to your liking. Only lines with a variable assignment are evaluated, # it should follow bash syntax, note though that ';' can't be used # currently in the variable values; e.g.: # # # Sample configuration for vcs # user=myname # Sign all compositions as myname # bg_heading=gray # Make the heading gray # # The variables that can be overriden are below the block of constants ahead. declare -r CFGFILE=~/.vcs.conf # Constants {{{ # see $decoder declare -ri DEC_MPLAYER=1 DEC_FFMPEG=3 # See $timecode_from declare -ri TC_INTERVAL=4 TC_NUMCAPS=8 # These can't be overriden, modify this line if you feel the need declare -r PROGRAM_SIGNATURE="with Video Contact Sheet *NIX ${VERSION} " # }}} # End of constants # Override-able variables {{{ declare -i DEFAULT_INTERVAL=300 declare -i DEFAULT_NUMCAPS=16 declare -i DEFAULT_COLS=2 # Text before the user name in the signature declare user_signature="Preview created by" # By default sign as the system's username declare user=$(id -un) # Which of the two methods should be used to guess the number of thumbnails declare -i timecode_from=$TC_INTERVAL # Which of the two vidcappers should be used (see -F, -M) # mplayer seems to fail for mpeg or WMV9 files, at least on my system declare -i decoder=$DEC_FFMPEG # Options used in imagemagick, these options set the final aspect # of the contact sheet declare output_format=png # ImageMagick decides the type from the extension declare -i output_quality=92 # Output image quality (only affects the final # image and obviously only in lossy formats) # Colours, see convert -list color to get the list declare bg_heading=YellowGreen # Background for meta info (size, codec...) declare bg_sign=SlateGray # Background for signature declare fg_heading=black # Font colour for meta info box declare fg_sign=black # Font colour for signature declare fg_tstamps=white # Font colour for timestamps # Fonts, see convert -list type to get the list declare font_tstamps=courier # Used for timestamps behind the thumbnails declare font_heading=helvetica # Used for meta info box declare font_sign=$font_heading # Used for the signature box # Font sizes, in points declare pts_tstamps=18 # Used for the timestamps declare pts_meta=16 # Used for the meta info box declare pts_sign=11 # Used for the signature # See --shoehorn declare shoehorned= # }}} # End of override-able variables # Options and other internal usage variables, no need to mess with this! declare -i interval=$DEFAULT_INTERVAL # Interval of captures (=numsecs/numcaps) declare -i numcaps=$DEFAULT_NUMCAPS # Number of captures (=numsecs/interval) declare title="" declare -i fromtime=0 # Starting second (see -f) declare -i totime=-1 # Ending second (see -t) declare -a initial_stamps=( ) # Manually added stamps (see -S) declare -i th_height= # Height of the thumbnails, by default use same as input declare -i cols=$DEFAULT_COLS # Number of output columns declare -i manual_mode=0 # if 1, only command line timestamps will be used declare -a TEMPSTUFF=( ) # Temporal files # Exit codes, same codes as /usr/include/sysexits.h declare -r EX_OK=0 EX_USAGE=64 EX_UNAVAILABLE=69 \ EX_NOINPUT=66 EX_SOFTWARE=70 EX_CANTCREAT=73 \ EX_INTERRUPTED=79 # This one is not on sysexits.h load_config() { # These are the variables allowed to be overriden in the config file, # please. # They're REGEXes, they'll be concatenated to form a regex like # (override1|override2|...). # Don't mess with this unless you're pretty sure of what you're doing. # All this extra complexity is done to avoid including the config # file directly for security reasons. declare -ra ALLOWED_OVERRIDES=( 'user' 'user_signature' 'bg_.*' 'font_.*' 'pts_.*' 'fg_.*' 'output_quality' 'DEFAULT_INTERVAL' 'DEFAULT_NUMCAPS' 'DEFAULT_COLS' 'decoder' 'output_format' 'shoehorned' 'timecode_from' ) if [ ! -f "$CFGFILE" ]; then return 0 ; fi local compregex=$( sed 's/ /|/g' <<<${ALLOWED_OVERRIDES[*]} ) while read line ; do # auto variable $line # Don't allow ';', FIXME: dunno how secure that really is if ! egrep -q '^[[:space:]]*[a-zA-Z_][a-zA-Z0-9_]*=[^;]*' <<<"$line" ; then continue fi if ! egrep -q "^($compregex)=" <<<"$line" ; then continue fi # FIXME: Only print in verbose mose # FIXME: Only for really overridden ones echo "Overridden variable" $(sed -r 's/^[[:space:]]*([a-zA-Z0-9_]*)=.*/\1/'<<<$line) eval $line done <$CFGFILE } # {{{ # Convenience functions is_number() { egrep -q '^[0-9]+$' <<<"$1" return $? } # The current code is a tad permissive, it allows e.g. things like # 10m1h (equivalent to 1h10m) # 1m1m (equivalent to 2m) get_interval() { if is_number "$1" ; then echo $1 ; return 0 ; fi local s=$(tr '[A-Z]' '[a-z]' <<<"$1") # Only allowed characters if ! grep -q '[0-9smh]' <<<"$s"; then return $EX_USAGE; fi # FIXME: Find some cleaner way local i= c= num= sum=0 for i in $(seq 0 $(( ${#s} - 1)) ); do c=${s:$i:1} if is_number $c ; then num+=$c else case $c in h) num=$(($num * 3600)) ;; m) num=$(($num * 60)) ;; s) ;; *) return $EX_SOFTWARE ;; esac sum=$(($sum + $num)) num= fi done # If last element was a number, it's seconds and they weren't added if is_number $c ; then sum=$(( $sum + $num )) fi echo $sum return 0 } pad() { local len=$1 local str=$2 while [ ${#str} -lt $len ]; do str=0$str done echo $str } pretty_stamp() { if ! is_number "$1" ; then return $EX_USAGE ; fi local t=$1 local h=$(( $t / 3600 )) t=$(( $t % 3600 )) local m=$(( $t / 60 )) t=$(( $t % 60 )) local s=$t local R="" if [ $h -gt 0 ]; then R+="$h:" fi R+=$(pad 2 "$m"):$(pad 2 $s) echo $R } get_pretty_size() { local f="$1" local bytes=$(du -DL --bytes "$f" | cut -f1) local size="" if [ "$bytes" -gt $(( 1024**3 )) ]; then local gibs=$(( $bytes / 1024**3 )) local mibs=$(( ( $bytes % 1024**3 ) / 1024**2 )) size="${gibs}.${mibs:0:2} GiB" elif [ "$bytes" -gt $(( 1024**2)) ]; then local mibs=$(( $bytes / 1024**2 )) local kibs=$(( ( $bytes % 1024**2 ) / 1024 )) size="${mibs}.${kibs:0:2} MiB" elif [ "$bytes" -gt 1024 ]; then local kibs=$(( $bytes / 1024 )) bytes=$(( $bytes % 1024 )) size="${kibs}.${bytes:0:2} KiB" else size="${bytes} B" fi echo $size } # Rename a file, if the target exists, try with -1, -2, ... safe_rename() { local from="$1" local to="$2" local ext=$(sed -r 's/.*\.(.*)/\1/g' <<<"$to") local n=1 while [ -f "$to" ]; do to="$(basename "$2" .$ext)-$n.$ext" n=$(( $n + 1)) done mv "$from" "$to" echo "$to" >&2 } test_programs() { for prog in mplayer convert montage ffmpeg ; do type -pf "$prog" >/dev/null local retval=$? if [ $retval -ne 0 ] ; then error "Required program $prog not found!" return $retval fi done } cleanup() { if [ -z $TEMPSTUFF ]; then return 0 ; fi echo "Cleaning up..." >&2 # TODO: Only in verbose mode rm -rf ${TEMPSTUFF[*]} TEMPSTUFF=( ) } exithdlr() { cleanup } # Print some text to stderr error() { echo "$1" >&2 } # }}} # Convenience functions # {{{ # Core functionality process() { local f=$1 local numcols=$cols if [ ! -f "$f" ]; then error "File \"$f\" doesn't exist" return $EX_NOINPUT fi echo "Processing $f..." >&2 # Meta data extraction # Don't change the -vc as it would affect $vdec local mplayer_cache=$(mplayer -benchmark -ao null -vo null -identify -frames 0 -quiet "$f" 2>/dev/null | grep ^ID) local vcodec=$(grep ID_VIDEO_FORMAT <<<"$mplayer_cache" | cut -d'=' -f2) # FourCC local acodec=$(grep ID_AUDIO_FORMAT <<<"$mplayer_cache" | cut -d'=' -f2) local vdec=$(grep ID_VIDEO_CODEC <<<"$mplayer_cache" | cut -d'=' -f2) # Decoder local width=$(grep ID_VIDEO_WIDTH <<<"$mplayer_cache" | cut -d'=' -f2) local height=$(grep ID_VIDEO_HEIGHT <<<"$mplayer_cache" | cut -d'=' -f2) local fps=$(grep ID_VIDEO_FPS <<<"$mplayer_cache" | cut -d'=' -f2) # Vidcap/Thumbnail height local vidcap_height=$th_height if ! is_number "$vidcap_height" || [ "$vidcap_height" -eq 0 ]; then vidcap_height=$height fi local numsecs=$(grep ID_LENGTH <<<"$mplayer_cache"| cut -d'=' -f2 | cut -d. -f1) if ! is_number $numsecs ; then error "Internal error!" return $EX_SOFTWARE fi local nc=$numcaps local in=$interval # Start bound: local startsec=0 if [ $startsec -lt $fromtime ]; then startsec=$fromtime fi # End bound: local endsec=$numsecs if [ $totime -ne -1 ] && [ $endsec -gt $totime ]; then endsec=$totime fi if [ $startsec -ne 0 ] || [ $endsec -ne $numsecs ]; then echo "Restricting to range [$startsec..$endsec]" >&2 fi local delta=$(( $endsec - $startsec )) # FIXME: the total # of caps is currently broken when using -f and -t # Note that when numcaps mandates the interval is obtained from # the actually allowed secods (hence it is not movie_length / numcaps ) # Adjust interval/numcaps: if [ "$timecode_from" -eq "$TC_INTERVAL" ]; then # Interval rules => it doesn't change nc=$(( $delta / $interval )) # If a multiple, an extra vidcap is generated (at the last second) if [ $(( $delta % $interval )) -eq 0 ]; then nc=$(( $nc + 1 )) fi elif [ "$timecode_from" -eq "$TC_NUMCAPS" ]; then # Numcaps rules => it doesn't change if [ $numcaps -eq 1 ]; then # If just one cap, center it in=$(( $numsecs / 2 )) else in=$(( $numsecs / $numcaps )) fi else error "Internal error!" return $EX_SOFTWARE fi # Let's try to make some sense... # Minimum interval allowance: if [ $in -gt $numsecs ]; then error "The interval is longer than the video length." error "Use a lower interval or numcaps instead." error "Skipping \"$f\"." return $EX_USAGE fi # Contact sheet minimum cols: if [ $nc -lt $numcols ]; then numcols=$nc fi # Tempdir local dir=$(mktemp -d -p . vcs.XXXXXX) if [ "$?" -ne 0 ]; then error "Error creating temporary directory" return $EX_CANTCREAT fi TEMPSTUFF+=( "$dir" ) local n= # Get the stamps (if in auto mode)... local stamps=( ) if [ $manual_mode -ne 1 ]; then n=$(( $startsec + $in )) stamps=( ${initial_stamps[*]} $n ) while [ $n -le $endsec ]; do n=$(( $n + $in )) if [ $n -gt $endsec ]; then break; fi stamps=( ${stamps[*]} $n ) done else stamps=( ${initial_stamps[*]} ) fi n=1 local p="" local cap="" local montage_command="montage -font $font_tstamps -pointsize $pts_tstamps \ -gravity SouthEast -fill white " local output=$(tempfile --prefix "vcs-" --suffix '-preview.png' -d .) TEMPSTUFF+=( "$output" ) # Let's reorder the stamps, this away user-added stamps get their correct # position also remove duplicates. Note AFAIK sort only sorts lines, that's # why y replace spaces by newlines. # # Note that stamps keeps being an array and stamps[1..N] still hold their # old values, although it's treated as a string after this stamps=$( sed 's/ /\n/g' <<<"${stamps[*]}" | sort -n | uniq ) local VIDCAPFILE=00000001.png # If the temporal vidcap already exists, abort if [ -f $VIDCAPFILE ]; then error "Temporal vidcap file ($VIDCAPFILE) exists, remove it before running!." return $EX_CANTCREAT fi local NUMSTAMPS=$(wc -w <<<"$stamps") TEMPSTUFF+=( $VIDCAPFILE ) # TODO: Aspect ratio for stamp in $stamps; do # Note that it must be checked against numsecs and not endsec, to allow # the user manually setting stamps beyond the boundaries if [ $stamp -gt $numsecs ]; then continue; fi echo "Generating capture #${n}/${NUMSTAMPS}..." >&2 if [ $decoder -eq $DEC_MPLAYER ]; then mplayer -sws 9 -ao null -benchmark -vo "png:z=0" -quiet \ -frames 1 -ss $stamp $shoehorned "$f" >/dev/null 2>&1 elif [ $decoder -eq $DEC_FFMPEG ]; then ffmpeg -y -ss $stamp -i "$f" -an -dframes 1 -vframes 1 -vcodec png \ -f rawvideo $shoehorned $VIDCAPFILE >/dev/null 2>&1 else error "Internal error!" return $EX_SOFTWARE fi || { local retval=$? error "The capturing program failed!" return $retval } p=$(pad 6 $stamp).png # mv 00000001.png $dir/$p n=$(( $n + 1 )) cap=$dir/$p # Add the timestamp to each vidcap, doing it hear is much powerful/simple # than with the next montage command convert -box '#000000aa' \ -fill $fg_tstamps -pointsize $pts_tstamps -gravity SouthEast \ -stroke none -strokewidth 3 -annotate +5+5 " $(pretty_stamp $stamp) " \ $VIDCAPFILE "$cap" montage_command+=" $cap" done # geometry affects the source images, not the target one! # Note the file name could also be added by using the "-title" option, but I reserved # it for used set titles montage_command+=" -geometry x${vidcap_height}+10+5 -tile ${numcols}x -shadow" if [ "$title" ]; then montage_command+=" -font $font_heading -fill $fg_heading -title '$title'" fi montage_command+=" $output" echo "Composing contact sheet..." >&2 eval $montage_command # eval is required to evaluate correctly the text in quotes! mv "$output" . 2>/dev/null || true # Codec "prettyfication" case $( tr '[A-Z]' '[a-z]' <<<$vcodec) in xvid) vcodec=Xvid ;; dx50) vcodec="DivX 5" ;; 0x10000001) vcodec="MPEG-1" ;; 0x10000002) vcodec="MPEG-2" ;; avc1) vcodec="MPEG-4 AVC" ;; wmv2) vcodec="WMV 8" ;; # WMV2 is v8 esac if [ "$vdec" == "ffodivx" ]; then vcodec+=" (MPEG-4)" elif [ "$vdec" == "ffh264" ]; then vcodec+=" (h.264)" fi case $( tr '[A-Z]' '[a-z]' <<<$acodec ) in 85) acodec='MPEG-1 Layer III (MP3)' ;; 80) acodec='MPEG-1 Layer II (MP2)' ;; mp4a) acodec='MPEG-4 AAC' ;; 353) acodec='WMA 2' ;; "") acodec="no audio" ;; esac local meta="Filename: $f File size: $(get_pretty_size "$f") Length: $(pretty_stamp "$numsecs")" local meta2="Dimensions: ${width}x${height} Format: $vcodec / $acodec FPS: $fps" local signature="$user_signature $user $PROGRAM_SIGNATURE" # Now let's add meta info # This one enlarges the image to add the text, and puts # meta info in two columns convert -font $font_heading -pointsize $pts_meta \ -background $bg_heading -fill $fg_heading -splice 0x$(( $pts_meta * 4 )) \ -gravity NorthWest -draw "text 10,10 '$meta'" \ -gravity NorthEast -draw "text 10,10 '$meta2'" \ "$output" "$output" # Finishing touch, signature convert -gravity South -font $font_sign -pointsize $pts_sign \ -background $bg_sign -splice 0x34+0-0 \ -fill $fg_sign -draw "text 10,3 '$signature'" "$output" "$output" if [ $output_format != "png" ]; then local newout="$(basename "$output" .png).$output_format" convert -quality $output_quality "$output" "$newout" output="$newout" fi echo -n "Output wrote to " >&2 safe_rename "$output" "$(basename "$f").$output_format" cleanup } show_help() { local P=$(basename $0) cat < Options: -i|--interval Set the interval to arg. An optional unit can be used (case-insensitive) , e.g.: Seconds: 90 Minutes: 3m Hours: 1h Use either -i or -n. -n|--numcaps Set the number of captured images to arg. Use either -i or -n. -f|--from Set starting time. No caps before this. Same format as -i. -t|--to Set ending time. No caps beyond this. Same format as -i. -T|--title Add a title above the vidcaps. -u|--user Set the username found in the signature to this. -S|--stamp Add the image found at the timestamp "arg", same format as -i. -m|--manual Manual mode: Only timestamps indicated by the user are used (use in conjunction with -S), when using this -i and -n are ignored. -H|--height Set the output (individual thumbnail) height. Width is derived accordingly. Note width cannot be manually set. -j|--jpeg Output in jpeg (by default output is in png). -h|--help Show this text. Options used for debugging purposes or to tweak the internal workings: -M|--mplayer Force the usage of mplayer. -F|--ffmpeg Force the usage of ffmpeg. --shoehorn Pass "arg" to mplayer/ffmpeg. You probably don't need it. Examples: Create a contact sheet with default values (vidcaps at intervals of $DEFAULT_INTERVAL seconds), the resulting file will be called input.avi.png: \$ $P input.avi Create a sheet with vidcaps at intervals of 3 and a half minutes: \$ $P -i 3m30 input.avi Create a sheet with vidcaps starting at 3 mins and ending at 18 mins, add an extra vidcap at 2m and another one at 19m: \$ $P -f 3m -t 18m -S2m -S 19m input.avi EOF } # }}} # Core functionality #### Execution starts here #### # Execute exithdlr on exit trap exithdlr EXIT # Test requirements test_programs || exit $EX_UNAVAILABLE load_config # {{{ # Command line parsing # Based on getopt-parse.bash example. # On debian systems see # FIXME: use username / no name at all with -u noarg, and not -u TEMP=$(getopt -o i:n:u:T:f:t:S:jhFMH:c:m \ --long "interval:,numcaps:,username:,title:,from:,to:,stamp:,jpeg,help,shoehorn:,mplayer,ffmpeg,height:,columns:,manual" \ -n $0 -- "$@") eval set -- "$TEMP" while true ; do case "$1" in -i|--interval) if ! interval=$(get_interval "$2") ; then error "Interval must be a number (given $2)" exit $EX_USAGE fi if [ "$interval" -le 0 ]; then error "Interval must be higher than 0, set to the default $DEFAULT_INTERVAL" interval=$DEFAULT_INTERVAL fi timecode_from=$TC_INTERVAL shift # Option arg ;; -n|--numcaps) if ! is_number "$2" ; then error "Number of caps must be a number! (given $2)" exit $EX_USAGE fi numcaps="$2" timecode_from=$TC_NUMCAPS shift # Option arg ;; -u|--username) user="$2" ; shift ;; -T|--title) title="$2" ; shift ;; -f|--from) if ! fromtime=$(get_interval "$2") ; then error "Starting timestamp must be a valid interval" exit $EX_USAGE fi shift ;; -t|--to) if ! totime=$(get_interval "$2") ; then error "Ending timestamp must be a valid interval" exit $EX_USAGE fi if [ "$totime" -eq 0 ]; then error "Ending timestamp was set to 0, set to movie length" totime=-1 fi shift ;; -S|--stamp) if ! temp=$(get_interval "$2") ; then error "Timestamps must be a valid interval" exit $EX_USAGE fi initial_stamps=( ${initial_stamps[*]} $temp ) shift ;; -j|--jpeg) output_format=jpg ;; -h|--help) show_help ; exit $EX_OK ;; --shoehorn) shoehorned="$2" shift ;; -F) decoder=$DEC_FFMPEG ;; -M) decoder=$DEC_MPLAYER ;; -H|--height) if ! is_number "$2" ; then error "Height must be a number (given $2)" exit $EX_USAGE fi th_height="$2" shift ;; -c|--columns) if ! is_number "$2" ; then error "Columns must be a number (given $2)" exit $EX_USAGE fi cols="$2" shift ;; -m|--manual) manual_mode=1 ;; --) shift ; break ;; *) error "Internal error! (remaining opts: $@)" ; exit $EX_SOFTWARE ; esac shift done # Remaining arguments if [ ! "$1" ]; then show_help exit $EX_USAGE fi # If -m is used then -S must be used if [ $manual_mode -eq 1 ] && [ -z $initial_stamps ]; then error "You must provide timestamps (-S) when using manual mode (-m)" exit $EX_USAGE fi set +e # Don't fail automatically for arg do process "$arg" ; done # }}} # Command line parsing # vim:set ts=4 ai: #