# # Contact Sheet Composited from the Thumbnails # require 'fileutils' require 'tmpdir' require 'yaml' module VCSRuby class ContactSheet attr_accessor :signature, :title, :highlight attr_accessor :softshadow, :timestamp, :polaroid attr_reader :thumbnail_width, :thumbnail_height, :aspect_ratio, :format attr_reader :length, :from, :to def initialize video, capturer @video = video @capturer = capturer @signature = "Created by Video Contact Sheet Ruby" initialize_filename if Configuration.instance.verbose? puts "Processing #{File.basename(video.full_path)}..." end return unless @video.valid? detect_video_properties @from = TimeIndex.new 0 @to = @video.info.duration @timestamp = Configuration.instance.timestamp @softshadow = Configuration.instance.softshadow @polaroid = Configuration.instance.polaroid @tempdir = Dir.mktmpdir ObjectSpace.define_finalizer(self, self.class.finalize(@tempdir) ) initialize_geometry(Configuration.instance.rows, Configuration.instance.columns, Configuration.instance.interval) end def initialize_filename override = nil @out_path = File.dirname(@video.full_path) @out_filename = File.basename(override || @video.full_path,'.*') extension = override ? File.extname(override) : '' @format = extension.length > 0 ? extension : '.png' end def initialize_geometry(rows, columns, interval) @has_interval = !!interval @rows = rows @columns = columns @interval = interval end def filename "#{@out_filename}#{@format}" end def format= format @format = ".#{format.to_s}" end def full_path File.join(@out_path, filename) end def rows @rows end def columns @columns end def interval @interval || (@to - @from) / (number_of_caps) end def number_of_caps if @has_interval (@to - @from) / @interval else if @rows && @columns @rows * @columns else raise "you need at least 2 parameters from columns, rows and interval" end end end def thumbnail_width= width @thumbnail_height = (width.to_f / @thumbnail_width * thumbnail_height).to_i @thumbnail_width = width end def thumbnail_height= height @thumbnail_width = (height.to_f / @thumbnail_height * thumbnail_width).to_i @thumbnail_height = height end def aspect_ratio= aspect_ratio @thumbnail_width = (@thumbnail_height * aspect_ratio).to_i @aspect_ratio = aspect_ratio end def from= time if (TimeIndex.new(0) < time) && (time < to) && (time < @length) @from = time else raise "Invalid From Time" end end def to= time if (TimeIndex.new(0) < time) && (from < time) && (time < @length) @to = time else raise "Invalid To Time" end end def build if (@video.info.duration.total_seconds < 1.0) puts "Video is shorter than 1 sec" else initialize_filters initialize_thumbnails capture_thumbnails puts "Composing standard contact sheet..." unless Configuration.instance.quiet? montage = splice_montage(montage_thumbs) image = MiniMagick::Image.open(montage) puts "Adding header and footer..." unless Configuration.instance.quiet? final = add_header_and_footer image puts "Done. Output wrote to '#{filename}'" unless Configuration.instance.quiet? FileUtils.mv(final, full_path) end end private def self.finalize(tempdir) proc do puts "Cleaning up..." unless Configuration.instance.quiet? FileUtils.rm_r tempdir end end def initialize_filters @filters = [] @filters << :resize_filter @filters << :softshadow_filter if softshadow @filters << :timestamp_filter if timestamp @filters << :polaroid_filter if polaroid end def initialize_thumbnails @thumbnails = [] time = @from + (interval / 2) (1..number_of_caps).each do |i| thumb = Frame.new @video, @capturer, time time = time + interval thumb.width = thumbnail_width thumb.height = thumbnail_height thumb.filename = File::join(@tempdir, "th#{"%03d" % i}.#{@capturer.format_extension}") thumb.filters.push(*@filters) @thumbnails << thumb end end def capture_thumbnails puts "Capturing in range [#{from}..#{to}]. Total length: #{@length}" unless Configuration.instance.quiet? @thumbnails.each_with_index do |thumbnail, i| puts "Generating capture ##{i + 1}/#{number_of_caps} #{thumbnail.time}..." unless Configuration.instance.quiet? if Configuration.instance.blank_evasion? thumbnail.capture_and_evade interval else thumbnail.capture end thumbnail.apply_filters end end def detect_video_properties detect_length detect_dimensions end def detect_length @length = @video.info.duration @from = TimeIndex.new 0.0 @to = @length end def detect_dimensions @thumbnail_width = @video.video.width @thumbnail_height = @video.video.height @aspect_ratio = @video.video.aspect_ratio if @aspect_ratio == 0 || @aspect_ratio == nil @aspect_ratio = Rational(@video.video.width, @video.video.height) else #recalculate width, for PAR 1:1 this should be the same as before @thumbnail_width = (@aspect_ratio * @video.video.height).to_i end end def montage_thumbs file_path = File::join(@tempdir, 'montage.png') MiniMagick::Tool::Montage.new do |montage| montage.background Configuration.instance.contact_background @thumbnails.each do |thumbnail| montage << thumbnail.filename end montage.geometry "+#{Configuration.instance.padding}+#{Configuration.instance.padding}" # rows or columns can be nil (auto fit) montage.tile "#{@columns}x#{@rows}" montage << file_path end return file_path end def splice_montage montage_path if softshadow left = Configuration.instance.padding + 3 top = Configuration.instance.padding + 5 bottom = right = Configuration.instance.padding else left = right = top = bottom = Configuration.instance.padding end file_path = File::join(@tempdir, 'spliced.png') MiniMagick::Tool::Convert.new do |convert| convert << montage_path convert.background Configuration.instance.contact_background convert.splice "#{left}x#{top}" convert.gravity 'SouthEast' convert.splice "#{right}x#{bottom}" convert << file_path end file_path end def create_title montage file_path = File::join(@tempdir, 'title.png') MiniMagick::Tool::Convert.new do |convert| convert.stack do |ul| ul.size "#{montage.width}x#{Configuration.instance.title_font.line_height}" ul.xc Configuration.instance.title_background if Configuration.instance.title_font.exists? ul.font Configuration.instance.title_font.path end ul.pointsize Configuration.instance.title_font.size ul.background Configuration.instance.title_background ul.fill Configuration.instance.title_color ul.gravity 'Center' ul.annotate(0, @title) end convert.flatten convert << file_path end return file_path end def create_highlight montage puts "Generating highlight..." thumb = Frame.new @video, @capturer, @highlight thumb.width = thumbnail_width thumb.height = thumbnail_height thumb.filename = File::join(@tempdir, "highlight_thumb.png") thumb.capture thumb.apply_filters file_path = File::join(@tempdir, "highlight.png") MiniMagick::Tool::Convert.new do |convert| convert.stack do |a| a.size "#{montage.width}x#{thumbnail_height+20}" a.xc Configuration.instance.highlight_background a.gravity 'Center' a << thumb.filename a.composite end convert.stack do |a| a.size "#{montage.width}x1" a.xc 'Black' end convert.append convert << file_path end file_path end def add_header_and_footer montage file_path = File::join(@tempdir, filename) header_height = Configuration.instance.header_font.line_height * 3 signature_height = Configuration.instance.signature_font.line_height + 8 MiniMagick::Tool::Convert.new do |convert| convert.stack do |a| a.size "#{montage.width - 18}x1" a.xc Configuration.instance.header_background a.size.+ if Configuration.instance.header_font.exists? a.font Configuration.instance.header_font.path end a.pointsize Configuration.instance.header_font.size a.background Configuration.instance.header_background a.fill Configuration.instance.header_color a.stack do |b| b.gravity 'West' b.stack do |c| c.label 'Filename: ' if Configuration.instance.header_font.exists? c.font Configuration.instance.header_font.path end c.label File.basename(@video.full_path) c.append.+ end if Configuration.instance.header_font.exists? b.font Configuration.instance.header_font.path end b.label "File size: #{Tools.to_human_size(File.size(@video.full_path))}" b.label "Length: #{@length.to_timestamp}" b.append b.crop "#{montage.width}x#{header_height}+0+0" end a.append a.stack do |b| b.size "#{montage.width}x#{header_height}" b.gravity 'East' b.fill Configuration.instance.header_color b.annotate '+0-1' b << "Dimensions: #{@video.video.width}x#{@video.video.height}\nFormat: #{@video.video.codec(true)} / #{@video.audio ? @video.audio.codec(true) : 'no audio'}\nFPS: #{"%.02f" % @video.video.frame_rate.to_f}" end a.bordercolor Configuration.instance.header_background a.border 9 end convert << create_title(montage) if @title convert << create_highlight(montage) if @highlight convert << montage.path convert.append if @signature && @signature.length > 0 convert.stack do |a| a.size "#{montage.width}x#{signature_height}" a.gravity 'Center' a.xc Configuration.instance.signature_background if Configuration.instance.signature_font.exists? a.font Configuration.instance.signature_font.path end a.pointsize Configuration.instance.signature_font.size a.fill Configuration.instance.signature_color a.annotate(0, @signature) end convert.append end if @format == :jpg || @format == :jpeg convert.quality(Configuration.instance.quality) end convert << file_path end file_path end end end