require "open3" module Streams # Converte cover_image in MP4 slate (H.264 720p + AAC mono), allineato a infra/scripts/generate_slate.sh. class GenerateCoverSlate class Error < StandardError; end SLATE_DURATION_SECS = 30 VIDEO_FILTER = "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=0x0a0a0e".freeze def self.call(record) new(record).call end def initialize(record) @record = record end def call return @record unless @record.cover_image.attached? @image_temp = download_cover_image @mp4_temp = encode_slate(@image_temp) attach_slate(@mp4_temp) write_to_slates_dir(@mp4_temp) @record rescue Error raise rescue StandardError => e raise Error, e.message ensure cleanup_tempfiles end private def download_cover_image ext = extension_for(@record.cover_image.content_type) path = temp_path("cover-in", ext) @record.cover_image.blob.open(tmpdir: Dir.tmpdir) do |file| FileUtils.cp(file.path, path) end path end def encode_slate(image_path) out = temp_path("cover-slate", ".mp4") cmd = ffmpeg_args(image_path, out) stdout, stderr, status = Open3.capture3(*cmd) unless status.success? && File.exist?(out) && File.size(out).positive? detail = stderr.to_s.strip.presence || stdout.to_s.strip raise Error, "ffmpeg slate failed: #{detail}" end out end def ffmpeg_args(input, output) [ "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-loop", "1", "-framerate", "30", "-i", input, "-f", "lavfi", "-i", "anullsrc=r=48000:cl=mono", "-filter:v", VIDEO_FILTER, "-map", "0:v", "-map", "1:a", "-t", SLATE_DURATION_SECS.to_s, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-profile:v", "baseline", "-level", "3.1", "-x264-params", "keyint=30:min-keyint=30:scenecut=0:bframes=0", "-g", "30", "-keyint_min", "30", "-force_key_frames", "expr:gte(t,n_forced*1)", "-preset", "fast", "-c:a", "aac", "-b:a", "128k", "-ac", "1", "-shortest", "-y", output ] end def attach_slate(mp4_path) @record.cover_slate.purge if @record.cover_slate.attached? File.open(mp4_path, "rb") do |io| @record.cover_slate.attach( io: io, filename: slate_filename, content_type: "video/mp4" ) end @record.reload end def write_to_slates_dir(mp4_path) dest = CoverSlatePaths.expected_path(@record) return unless dest FileUtils.mkdir_p(File.dirname(dest)) FileUtils.cp(mp4_path, dest) rescue Errno::EACCES, Errno::EROFS => e Rails.logger.info("[GenerateCoverSlate] skip disk copy #{dest}: #{e.class}") end def slate_filename "#{@record.class.name.underscore}-#{@record.id}.mp4" end def extension_for(content_type) case content_type when "image/png" then ".png" when "image/webp" then ".webp" else ".jpg" end end def temp_path(prefix, ext) path = File.join(Dir.tmpdir, "#{prefix}-#{@record.class.name}-#{@record.id}-#{SecureRandom.hex(4)}#{ext}") @tempfiles ||= [] @tempfiles << path path end def cleanup_tempfiles Array(@tempfiles).each do |path| FileUtils.rm_f(path) end end end end