Permette upload da portale e app con ereditarietà partita→squadra→società, generazione MP4 via ffmpeg e distribuzione su home lab e nodi CPX per pause e assenza segnale. Co-authored-by: Cursor <cursoragent@cursor.com>
122 lines
3.4 KiB
Ruby
122 lines
3.4 KiB
Ruby
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
|