203 lines
10 KiB
Ruby
203 lines
10 KiB
Ruby
# SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
# SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
require "tmpdir"
|
|
require_relative "screenshots"
|
|
|
|
# frameit owns composition. Only staged copies are resized; source captures are
|
|
# read-only, and the website's existing feature/file names remain stable.
|
|
class ScreenshotFrames
|
|
DEVICES = %w[iphone iphone-duo-outer iphone-duo-inner ipad mac].freeze
|
|
TEMPLATE_DIRECTORY = "com.akinokaede.termind/assets/device_templates".freeze
|
|
CUSTOM_FRAMES = {
|
|
"iphone" => { name: "iPhone 18 Pro", size: [1350, 2760], offset: "+72+69", width: 1206,
|
|
color: "Silver", template: "iphone-18-pro" },
|
|
"iphone-duo-outer" => { name: "iPhone Duo Outer", size: [1574, 2194], offset: "+88+80", width: 1398, color: "Star White" },
|
|
"iphone-duo-inner" => { name: "iPhone Duo Inner", size: [2247, 3093], offset: "+120+120", width: 2007, color: "Star White" }
|
|
}.freeze
|
|
WEBSITE_SCENES = {
|
|
"iphone" => %w[assistant ssh vault], "ipad" => %w[metrics sftp vault], "mac" => %w[workspace],
|
|
"iphone-duo-outer" => Screenshots::MOBILE_SCENES, "iphone-duo-inner" => Screenshots::MOBILE_SCENES
|
|
}.freeze
|
|
|
|
def initialize(options = {}, root: Screenshots::ROOT)
|
|
@root = root
|
|
@mac_frame = File.expand_path(options[:mac_frame] || "#{TEMPLATE_DIRECTORY}/macbook-pro-14-template.png", root)
|
|
@ipad_frame = File.expand_path(options[:ipad_frame] || "#{TEMPLATE_DIRECTORY}/ipad-pro-13-m5.png", root)
|
|
@custom_frames = CUSTOM_FRAMES.to_h do |device, specification|
|
|
option = device.tr("-", "_").concat("_frame").to_sym
|
|
[device, File.expand_path(options[option] || "#{TEMPLATE_DIRECTORY}/#{specification[:template] || device}.png", root)]
|
|
end
|
|
selector = Screenshots.new(:ios, {}, environment: {}, root: root)
|
|
@devices = selector.selection(options[:devices], %w[iphone ipad mac], DEVICES, "devices")
|
|
@locales = selector.selection(options[:locales], Screenshots::LOCALES.keys, Screenshots::LOCALES.keys, "locales")
|
|
@scenes = options[:scenes]
|
|
@uses_default_assets = !options[:input] || !options[:output]
|
|
@input = File.expand_path(options[:input] || "#{Screenshots::ASSET_DIRECTORY}/assets/captures", root)
|
|
@output = File.expand_path(options[:output] || "#{Screenshots::ASSET_DIRECTORY}/assets/device_frames", root)
|
|
@jobs = @devices.flat_map do |device|
|
|
scenes = device == "mac" ? ["workspace"] : Screenshots::MOBILE_SCENES
|
|
selected = selector.selection(@scenes, WEBSITE_SCENES.fetch(device), scenes, "#{device} scenes")
|
|
selected.product(@locales).map do |scene, language|
|
|
relative = "#{language.downcase}/#{Screenshots.asset_device_directory(device)}/#{scene}"
|
|
{ device: device, input: File.join(@input, "#{relative}.png"),
|
|
output: File.join(@output, "#{relative}.png") }
|
|
end
|
|
end
|
|
end
|
|
|
|
def plan
|
|
@jobs
|
|
end
|
|
|
|
def run
|
|
Screenshots.require_assets!(@root) if @uses_default_assets
|
|
require "frameit"
|
|
require "fastimage"
|
|
(@devices & CUSTOM_FRAMES.keys).each do |device|
|
|
specification = CUSTOM_FRAMES.fetch(device)
|
|
unless FastImage.size(@custom_frames.fetch(device)) == specification.fetch(:size)
|
|
raise "Missing or invalid #{specification.fetch(:name)} template: #{@custom_frames.fetch(device)}. " \
|
|
"Restore templates from the assets repository (git submodule update --init com.akinokaede.termind); " \
|
|
"or pass #{device.tr('-', '_')}_frame:/path/to/frame.png"
|
|
end
|
|
constant = device.tr("-", "_").upcase
|
|
unless Frameit::Devices.const_defined?(constant)
|
|
dimensions = Screenshots::DEVICES.fetch(device).fetch(:dimensions).sort
|
|
Frameit::Devices.const_set(constant, Frameit::Device.new(
|
|
device, "Apple #{specification.fetch(:name)}", 21,
|
|
[dimensions, dimensions.reverse], 460, specification.fetch(:color), Frameit::Platform::IOS
|
|
))
|
|
end
|
|
end
|
|
unless Frameit::Devices.const_defined?(:IPAD_PRO_13_CUSTOM)
|
|
Frameit::Devices.const_set(:IPAD_PRO_13_CUSTOM, Frameit::Device.new(
|
|
"ipad-pro-13-custom", "Apple iPad Pro 13-inch (M5)", 20,
|
|
[[2064, 2752], [2752, 2064]], 264, "Space Black", Frameit::Platform::IOS
|
|
))
|
|
end
|
|
if @devices.include?("ipad") && FastImage.size(@ipad_frame) != [2300, 3000]
|
|
raise "Missing or invalid iPad template: #{@ipad_frame}. " \
|
|
"Restore templates from the assets repository (git submodule update --init com.akinokaede.termind); " \
|
|
"or pass ipad_frame:/path/to/frame.png"
|
|
end
|
|
if @devices.include?("mac") && FastImage.size(@mac_frame) != [3860, 2320]
|
|
raise "Missing or invalid Mac template: #{@mac_frame}. " \
|
|
"Restore templates from the assets repository (git submodule update --init com.akinokaede.termind); " \
|
|
"or pass mac_frame:/path/to/frame.png"
|
|
end
|
|
@jobs.each do |job|
|
|
expected = job[:device] == "mac" ? [3024, 1964] : Screenshots::DEVICES.fetch(job[:device]).fetch(:dimensions)
|
|
actual = FastImage.size(job[:input])
|
|
raise "Recapture #{job[:input]}: expected native #{expected.join('x')}, found #{actual.inspect}" unless actual == expected
|
|
end
|
|
directory = File.join(@root, ".build", "fastlane-frames")
|
|
FileUtils.mkdir_p(directory)
|
|
Dir.mktmpdir("run-", directory) do |stage|
|
|
framefile = JSON.parse(File.read(File.join(@root, "fastlane", "Framefile.json")))
|
|
File.write(File.join(stage, "Framefile.json"), JSON.generate(framefile))
|
|
Dir.chdir(stage) do
|
|
downloader = Frameit::FrameDownloader.new
|
|
downloader.download_frames unless downloader.frames_exist?
|
|
end
|
|
if @devices.include?("ipad")
|
|
template = File.join(Frameit::FrameDownloader.templates_path, "Apple iPad Pro 13-inch (M5) Space Black.png")
|
|
FileUtils.cp(@ipad_frame, template)
|
|
end
|
|
(@devices & CUSTOM_FRAMES.keys).each do |device|
|
|
specification = CUSTOM_FRAMES.fetch(device)
|
|
name = "Apple #{specification.fetch(:name)} #{specification.fetch(:color)}.png"
|
|
FileUtils.cp(@custom_frames.fetch(device), File.join(Frameit::FrameDownloader.templates_path, name))
|
|
end
|
|
# Keep assets below device_frames so frameit never mistakes them for captures.
|
|
pending = @jobs.each_with_index.map do |job, index|
|
|
work = File.join(stage, index.to_s)
|
|
FileUtils.mkdir_p(work)
|
|
source = File.join(work, "#{job[:device]}.png")
|
|
FileUtils.cp(job[:input], source)
|
|
configuration = framefile.merge("default" => {})
|
|
force_device = case job[:device]
|
|
when *CUSTOM_FRAMES.keys
|
|
specification = CUSTOM_FRAMES.fetch(job[:device])
|
|
configuration["default"] = { "offset" => {
|
|
"offset" => specification.fetch(:offset), "width" => specification.fetch(:width)
|
|
} }
|
|
mask_screen(source, @custom_frames.fetch(job[:device]), specification.fetch(:offset),
|
|
rotation: job[:device] == "iphone-duo-inner" ? 90 : 0)
|
|
specification.fetch(:name)
|
|
when "ipad"
|
|
configuration["default"] = { "offset" => { "offset" => "+118+124", "width" => 2064 } }
|
|
mask_screen(source, @ipad_frame, "+118+124", rotation: -90)
|
|
"iPad Pro 13-inch (M5)"
|
|
end
|
|
File.write(File.join(work, "Framefile.json"), JSON.generate(configuration))
|
|
frame_options = { path: work, force_device_type: force_device, use_platform: "IOS" }
|
|
if job[:device] == "iphone-duo-inner"
|
|
frame_options[:force_orientation_block] = ->(_) { :landscape_right }
|
|
elsif job[:device] == "ipad"
|
|
frame_options[:force_orientation_block] = ->(_) { :landscape_left }
|
|
end
|
|
rendered = File.join(work, "#{job[:device]}_framed.png")
|
|
if job[:device] == "mac"
|
|
compose_mac(source, rendered)
|
|
else
|
|
yield(frame_options)
|
|
end
|
|
# frameit logs some image errors without failing the lane. Verify output
|
|
# explicitly, and publish nothing unless every requested frame succeeded.
|
|
size = FastImage.size(rendered)
|
|
raise "frameit did not produce a valid frame for #{job[:input]}" unless size && size.all?(&:positive?)
|
|
[rendered, job[:output]]
|
|
end
|
|
pending.each do |rendered, destination|
|
|
FileUtils.mkdir_p(File.dirname(destination))
|
|
temporary = "#{destination}.#{SecureRandom.hex(4)}.tmp"
|
|
begin
|
|
FileUtils.cp(rendered, temporary)
|
|
File.rename(temporary, destination)
|
|
ensure
|
|
FileUtils.rm_f(temporary)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def compose_mac(source, output)
|
|
# The official M5 14-inch aperture matches the 3024x1964 capture exactly.
|
|
# Only the template's 220px transparent top margin is removed in preparation.
|
|
Dir.mktmpdir("mac-screen-", File.dirname(output)) do |directory|
|
|
screen = File.join(directory, "screen.png")
|
|
FileUtils.cp(source, screen)
|
|
mask_screen(screen, @mac_frame, "+418+68")
|
|
command("magick", @mac_frame, "-colorspace", "sRGB", screen, "-gravity", "northwest", "-geometry", "+418+68",
|
|
"-compose", "DstOver", "-composite", output)
|
|
end
|
|
end
|
|
|
|
def mask_screen(source, template, offset, rotation: 0)
|
|
# The template has transparent pixels both inside the display and outside
|
|
# the device. Its opaque bezel separates them. Remove the exterior region
|
|
# before framing so rectangular capture corners cannot escape the bezel.
|
|
mask = File.join(File.dirname(source), "screen-mask.png")
|
|
command("magick", template, "-alpha", "extract", "-threshold", "99%", "-negate",
|
|
"-fill", "black", "-draw", "color 0,0 floodfill", mask)
|
|
center = command("magick", mask, "-format", "%[fx:p{w/2,h/2}]", "info:").strip
|
|
raise "No enclosed screen aperture in #{template}" unless center == "1"
|
|
|
|
dimensions = FastImage.size(source)
|
|
dimensions = dimensions.reverse unless rotation.zero?
|
|
arguments = ["magick", mask, "-crop", "#{dimensions.join('x')}#{offset}", "+repage"]
|
|
arguments += ["-rotate", rotation.to_s] unless rotation.zero?
|
|
command(*arguments, mask)
|
|
command("magick", source, "-alpha", "set", "(", mask, "-alpha", "copy", ")",
|
|
"-compose", "DstIn", "-composite", source)
|
|
FileUtils.rm_f(mask)
|
|
end
|
|
|
|
def command(*arguments)
|
|
output, status = Open3.capture2e(*arguments)
|
|
raise "#{arguments.join(' ')} failed:\n#{output}" unless status.success?
|
|
output
|
|
end
|
|
end
|