class Fastlane::Actions::FramerAction

Public Class Methods

authors() click to toggle source
# File lib/fastlane/plugin/framer/actions/framer_action.rb, line 465
def self.authors
  ["DrAL3X", "AzureRodrigo"]
end
available_options() click to toggle source
# File lib/fastlane/plugin/framer/actions/framer_action.rb, line 469
def self.available_options
  [
    FastlaneCore::ConfigItem.new(key: :source_folder,
      env_name: "FL_FRAMER_SOURCE_FOLDER",
      description: "Folder that contains screenshots to frame",
      is_string: true,
      default_value: "./fastlane/framer/screens",
      verify_block: proc do |value|
        UI.user_error!("Couldn't find folder at path '#{value}'") unless File.exist?(value)
      end),
    FastlaneCore::ConfigItem.new(key: :template_folder,
      env_name: "FL_FRAMER_TEMPLATE_FOLDER",
      description: "Folder that contains frames",
      is_string: true,
      default_value: "./fastlane/framer/templates",
      verify_block: proc do |value|
        UI.user_error!("Couldn't find folder at path '#{value}'") unless File.exist?(value)
      end),
    FastlaneCore::ConfigItem.new(key: :output_folder,
      env_name: "FL_FRAMER_OUTPUT_FOLDER",
      description: "Folder that will contains framed screenshots",
      is_string: true,
      default_value: "./fastlane/screenshots",
      verify_block: proc do |value|
        UI.user_error!("Couldn't find folder at path '#{value}'") unless File.exist?(value)
      end),
    FastlaneCore::ConfigItem.new(key: :output_suffix,
      env_name: "FL_FRAMER_OUTPUT_FILE_SUFFIX",
      description: "Suffix added to each framed screenshot in the output folder",
      is_string: true,
      default_value: "-framed")
  ]
end
combine(screenshot_file, template, colors, text, output_file, list_files, index) click to toggle source

Magic is HERE

# File lib/fastlane/plugin/framer/actions/framer_action.rb, line 244
def self.combine(screenshot_file, template, colors, text, output_file, list_files, index)

  # Get list lenght
  list_lenght = list_files.length()
  
  # Var  images
  screenshot_img_back = nil
  screenshot_img      = nil
  screenshot_img_next = nil

  # Prepare base image
  result_img = MiniMagick::Image.open("#{Framer::ROOT}/assets/background.png")
  result_img.resize "#{template.width}x#{template.height}!" # `!` says it should ignore the ratio

  # Apply background color, if any
  unless colors.background.nil?
    result_img.combine_options do |c|
      c.define "png:color-type=2"
      c.fill "#{colors.background}"
      c.draw "rectangle 0,0,#{template.width},#{template.height}"
    end
  end

  # Get template image
  template_img = MiniMagick::Image.open(template.file)
  
  # Get back screenshot
  unless template.imagePreviousOffset.nil?
    if list_lenght >= index -1 
      screenshot_img_back = MiniMagick::Image.open(list_files[index -1]).auto_orient
      screenshot_img_back.resize "#{template.imagePreviousWidth}x"
      unless template.imagePreviousRotation.nil?
        screenshot_img_back.combine_options do |cmd|
          cmd.background "rgba(255,255,255,0.0)" # transparent
          cmd.rotate(template.imagePreviousRotation.to_f)
        end
      end
    else
      UI.error "Unable to find back screenshot index #{index-1} in #{list_lenght}"
    end
  end

  # Get screenshot image
  screenshot_img = MiniMagick::Image.open(screenshot_file).auto_orient
  
  # Resize screenshot to fit template
  screenshot_img.resize "#{template.imageWidth}x"
  
  # rotate screenshot
  unless template.imageRotation.nil?
    screenshot_img.combine_options do |cmd|
      cmd.background "rgba(255,255,255,0.0)" # transparent
      cmd.rotate(template.imageRotation.to_f)
    end
  end

  # Get next screenshot
  unless template.imageNextOffset.nil?
    if list_lenght >= index + 1 
      image_path = list_files[index+1]
      list_files.each_with_index do |_file, _index|
        if _index === index
          print("file: ", _file, " index ", _index, " my index ", index)
        end
      end
      # print("aqui jovem: ", image_path, " list_lenght ", list_lenght, " index ", index)
      screenshot_img_next = MiniMagick::Image.open(list_files[index]).auto_orient
      screenshot_img_next.resize "#{template.imageNextWidth}x"
      unless template.imageNextRotation.nil?
        screenshot_img_next.combine_options do |cmd|
          cmd.background "rgba(255,255,255,0.0)" # transparent
          cmd.rotate(template.imageNextRotation.to_f)
        end
      end
    else
      UI.error "Unable to find next screenshot index #{index+1} in #{list_lenght}"
    end
  end

  # Put screenshot over template
  if template.imageBelow

    # Screenshot back
    unless screenshot_img_back.nil?
      result_img = result_img.composite(screenshot_img_back) do |c|
        c.compose "Over"
        c.geometry template.imagePreviousOffset.to_s

      end
    end

    # Screenshot first
    result_img = result_img.composite(screenshot_img) do |c|
      c.compose "Over"
      c.geometry template.imageOffset.to_s

    end

    # Screenshot next
    unless screenshot_img_next.nil?
      result_img = result_img.composite(screenshot_img_next) do |c|
        c.compose "Over"
        c.geometry template.imageNextOffset.to_s

      end
    end

    # Template second
    result_img = result_img.composite(template_img) do |c|
      c.compose "Over"
    end

  else

    # Template first
    result_img = result_img.composite(template_img) do |c|
      c.compose "Over"
    end

     # Screenshot back
     unless screenshot_img_back.nil?
      result_img = result_img.composite(screenshot_img_back) do |c|
        c.compose "Over"
        c.geometry template.imagePreviousOffset.to_s

      end
    end

    # Screenshot first
    result_img = result_img.composite(screenshot_img) do |c|
      c.compose "Over"
      c.geometry template.imageOffset.to_s

    end

    # Screenshot next
    unless screenshot_img_next.nil?
      result_img = result_img.composite(screenshot_img_next) do |c|
        c.compose "Over"
        c.geometry template.imageNextOffset.to_s

      end
    end

  end

  # Apply text, if any
  unless text.nil?
    # Clean text string before using it
    text.gsub! '\n', "\n"
    text.gsub!(/(?<!\\)(')/) { |s| "\\#{s}" } # escape unescaped apostrophes with a backslash

    # Create image with text
    text_img = MiniMagick::Image.open("#{Framer::ROOT}/assets/background.png")
    text_img.resize "2732x2732!" # Max space available. `!` says it should ignore the ratio

    text_font = template.textFont.nil? ? "Helvetica" : template.textFont

    text_img.combine_options do |c|
      c.font text_font
      c.pointsize template.textSize.to_s
      c.gravity "NorthWest"
      c.draw "text 0,0 '#{text}'"
      c.fill colors.text.to_s
    end
    text_img.trim # remove white space

    UI.verbose "text requires an area of #{text_img.width}x#{text_img.height}"

    # Scale down to fit space (if needed)
    available_width = (template.textWidth || template_img.width) - template.textPadding * 2
    available_height = template.textHeight

    ratio = available_width.to_f / text_img.width.to_f
    if ratio < 1
      UI.important "Scaling down text to fit in space (ratio: #{ratio.round(3)})"
      text_img.resize "#{available_width}x"
    end
    UI.verbose "text area is now #{text_img.width}x#{text_img.height}"

    # Put text image over template
    offset_x = ((available_width - text_img.width) / 2.0 + template.textPadding).round + template.textOffsetX
    offset_y = ((available_height - text_img.height) / 2.0).round + template.textOffsetY
    UI.verbose "text final offset x: #{offset_x} y: #{offset_y}"

    result_img = result_img.composite(text_img) do |c|
      c.compose "Over"
      c.geometry "+#{offset_x}+#{offset_y}"
    end

    text_img.destroy!
  end

  # Save result
  result_img.format "png"
  result_img.write output_file

  # Cleanup temp files
  result_img.destroy!
  screenshot_img.destroy!
  template_img.destroy!

end
create_dir_if_not_exists(path) click to toggle source
# File lib/fastlane/plugin/framer/actions/framer_action.rb, line 448
def self.create_dir_if_not_exists(path)
  recursive = path.split('/')
  directory = ''
  recursive.each do |sub_directory|
    directory += sub_directory + '/'
    Dir.mkdir(directory) unless (File.directory? directory)
  end
end
description() click to toggle source

@!group Documentation

# File lib/fastlane/plugin/framer/actions/framer_action.rb, line 461
def self.description
  "Create images combining app screenshots to templates to make a nice \'screenshot\' to upload in App Store and Google Play"
end
find_colors(source_dir, screenshot_file, colors_dir) click to toggle source
# File lib/fastlane/plugin/framer/actions/framer_action.rb, line 180
def self.find_colors(source_dir, screenshot_file, colors_dir)

  # Default values
  colors = Colors.new
  colors.text = "#000000"
  colors.background = nil

  # Read values from file
  directory = File.dirname(screenshot_file)
  colors_path = File.join(colors_dir, "colors.json")

  while directory.start_with?(source_dir) && !File.exist?(colors_path) do
    directory = File.dirname(directory)
    colors_path = File.join(directory, "colors.json")
  end

  if File.exist?(colors_path)
    config = JSON.parse(File.read(colors_path))

    # Read default values
    default = Colors.new
    default.text = config['default']['text']
    default.background = config['default']['background']
    colors.merge(default)

    # Read and apply override, if any
    override = config.select { |k, v| File.basename(screenshot_file).upcase.include? k.upcase }.values.map { |value|
      c = Colors.new
      c.text = value['text']
      c.background = value['background']
      c
    }
    unless override.empty?
      colors.merge(override.first)
    end
  end

  return colors
end
find_output(source_folder, screenshot_file, output_folder, output_suffix) click to toggle source
# File lib/fastlane/plugin/framer/actions/framer_action.rb, line 220
def self.find_output(source_folder, screenshot_file, output_folder, output_suffix)
  # Prepare file name
  if output_suffix.empty?
    file = File.basename(screenshot_file)
  else
    filename = File.basename(screenshot_file, ".*")
    extention = File.extname(screenshot_file)

    file = filename + output_suffix + extention
  end

  sub_path = File.dirname(screenshot_file).sub(source_folder, "")

  # Prepare file path
  file_path = File.join(File.join(output_folder, sub_path), file)

  # Ensure output dir exist
  folder = File.dirname(file_path)
  self.create_dir_if_not_exists(folder)

  return file_path
end
find_template(templates, screenshot_file, platform) click to toggle source
# File lib/fastlane/plugin/framer/actions/framer_action.rb, line 146
def self.find_template(templates, screenshot_file, platform)
  if [:ios, :mac].include? platform
    # Read device name from file
    filename = File.basename(screenshot_file)
    device = filename.slice(0, filename.rindex('-'))
  elsif :android == platform
    # Read device name from path
    folder = File.basename(File.dirname(screenshot_file))
    device = folder.slice(0, folder.rindex('S'))
  else
    UI.error "Unsupported platform"
  end

  # Search template that matches that size
  return templates.find { |template| template.name == device }
end
find_text(source_dir, screenshot_file) click to toggle source
# File lib/fastlane/plugin/framer/actions/framer_action.rb, line 163
def self.find_text(source_dir, screenshot_file)
  directory = File.dirname(screenshot_file)
  strings_path = File.join(directory, "text.json")

  while directory.start_with?(source_dir) && !File.exist?(strings_path) do
    directory = File.dirname(directory)
    strings_path = File.join(directory, "text.json")
  end

  return nil unless File.exist?(strings_path)

  text = JSON.parse(File.read(strings_path))

  result = text.find { |k, v| File.basename(screenshot_file).upcase.include? k.upcase }
  return result.last if result
end
is_supported?(platform) click to toggle source
# File lib/fastlane/plugin/framer/actions/framer_action.rb, line 503
def self.is_supported?(platform)
  true
end
load_templates(template_folder) click to toggle source
# File lib/fastlane/plugin/framer/actions/framer_action.rb, line 79
def self.load_templates(template_folder)
  json_file_path = "#{template_folder}/config.json"

  UI.user_error!("Missing Config.json file in template folder") unless File.exist?(json_file_path)

  # Read JSON configuration
  json_file = File.read(json_file_path)
  json_config = JSON.parse(json_file)

  config_default = json_config['default']

  # Detect available templates
  templates = []
  Dir.glob("#{template_folder}/**/*.png") do |file|

    name = File.basename(file, ".png")
    UI.message "Loading template #{name}"

    template = Template.new
    template.file = file
    template.name = name

    # Read template image size
    img = MiniMagick::Image.open(file)
    template.width = img.width
    template.height = img.height
    img.destroy!

    # Get template config
    config_custom = json_config[name]

    if config_custom.nil?
      UI.error "Missing configuration for template #{name}"
      next
    end

    # set image
    template.imageOffset   = (config_custom['image'] && config_custom['image']['offset']) || (config_default['image'] && config_default['image']['offset'])
    template.imageWidth    = (config_custom['image'] && config_custom['image']['width']) || (config_default['image'] && config_default['image']['width'])
    template.imageBelow    = (config_custom['image'] && config_custom['image']['add_below']) || (config_default['image'] && config_default['image']['add_below']) || false
    template.imageRotation = (config_custom['image'] && config_custom['image']['rotation']) || (config_default['image'] && config_default['image']['rotation'])

    # set image back
    template.imagePreviousOffset   = (config_custom['image']['previous'] && config_custom['image']['previous']['offset']) || (config_default['image']['previous'] && config_default['image']['previous']['offset'])
    template.imagePreviousWidth    = (config_custom['image']['previous'] && config_custom['image']['previous']['width']) || (config_default['image']['previous'] && config_default['image']['previous']['width'])
    template.imagePreviousRotation = (config_custom['image']['previous'] && config_custom['image']['previous']['rotation']) || (config_default['image']['previous'] && config_default['image']['previous']['rotation'])

    # set image next
    template.imageNextOffset   = (config_custom['image']['next'] && config_custom['image']['next']['offset']) || (config_default['image']['next'] && config_default['image']['next']['offset'])
    template.imageNextWidth    = (config_custom['image']['next'] && config_custom['image']['next']['width']) || (config_default['image']['next'] && config_default['image']['next']['width'])
    template.imageNextRotation = (config_custom['image']['next'] && config_custom['image']['next']['rotation']) || (config_default['image']['next'] && config_default['image']['next']['rotation'])

    # set font
    template.textFont      = (config_custom['text'] && config_custom['text']['font']) || (config_default['text'] && config_default['text']['font'])
    template.textSize      = (config_custom['text'] && config_custom['text']['size']) || (config_default['text'] && config_default['text']['size'])
    template.textWidth     = (config_custom['text'] && config_custom['text']['width']) || (config_default['text'] && config_default['text']['width'])
    template.textHeight    = (config_custom['text'] && config_custom['text']['height']) || (config_default['text'] && config_default['text']['height'])
    template.textPadding   = (config_custom['text'] && config_custom['text']['padding']) || (config_default['text'] && config_default['text']['padding']) || 0
    template.textOffsetX   = (config_custom['text'] && config_custom['text']['offset_x']) || (config_default['text'] && config_default['text']['offset_x']) || 0
    template.textOffsetY   = (config_custom['text'] && config_custom['text']['offset_y']) || (config_default['text'] && config_default['text']['offset_y']) || 0

    templates << template
  end

  return templates
end
run(params) click to toggle source
# File lib/fastlane/plugin/framer/actions/framer_action.rb, line 36
def self.run(params)
  source_folder = params[:source_folder]
  output_folder = params[:output_folder]
  template_folder = params[:template_folder]
  list_files = Dir.glob("#{source_folder}/**/*.png").sort
  templates = []
  platform = Actions.lane_context[Actions::SharedValues::PLATFORM_NAME]

  # Read config
  UI.success "Fetching templates from #{template_folder}"
  templates = self.load_templates(template_folder)

  # Process each screen
  UI.success "Processing screenshots from #{source_folder}"
  list_files.each_with_index do |file, index|
    UI.message "Processing #{file} index #{index}"

    template = self.find_template(templates, file, platform)
    if template.nil?
      UI.error "Unable to find template for screenshot #{file}"
      next
    end
    UI.verbose "Using template: #{template.name} (#{template.width}x#{template.height})"

    text = self.find_text(source_folder, file)
    UI.verbose "Using text: #{text}"

    colors = self.find_colors(source_folder, file, template_folder)
    UI.verbose "Using colors: #{colors}"

    output = self.find_output(source_folder, file, output_folder, params[:output_suffix])
    UI.verbose "Saving to: #{output}"

    # # Do the magic
    self.combine(file, template, colors, text, output, list_files, index)

    UI.verbose "Framed screenshot #{output}"
  end

  # Done
  UI.success "All screenshots are now framed!"
end