Files
redux-scraper/app/lib/loaded_media/webm_or_mp4.rb
2025-08-14 18:16:14 +00:00

114 lines
3.2 KiB
Ruby

# typed: strict
# frozen_string_literal: true
class LoadedMedia::WebmOrMp4 < LoadedMedia
include HasColorLogger
::FFMPEG.logger = Logger.new(nil)
FFMPEG_BIN = T.let(`which ffmpeg`.strip, String)
FFPROBE_BIN = T.let(`which ffprobe`.strip, String)
sig { params(media_path: String).void }
def initialize(media_path)
@media_path = media_path
@media = T.let(FFMPEG::Media.new(media_path), FFMPEG::Media)
duration = @media.duration
# frame_rate = @media.frame_rate
# @num_frames = T.let((frame_rate * duration).to_i, Integer)
output, error, status =
Open3.capture3(
FFPROBE_BIN,
"-v",
"error",
"-count_frames",
"-select_streams",
"v:0",
"-show_entries",
"stream=nb_read_frames",
"-of",
"default=nokey=1:noprint_wrappers=1",
media_path,
)
if status.success?
@num_frames = T.let(output.strip.to_i, Integer)
else
$stderr.print error
raise "Failed to get frame count from ffprobe: exit code #{status.exitstatus}"
end
@duration = T.let(duration, Float)
raise("no frames found in webm") if @num_frames.zero?
end
sig { override.returns(Integer) }
def num_frames
@num_frames
end
sig do
override
.params(frame: Integer, path: String, options: ThumbnailOptions)
.void
end
def write_frame_thumbnail(frame, path, options)
# Clamp the frame number to valid range for calculation
clamped_frame = frame.clamp(0, @num_frames - 1)
# Calculate frame time using the clamped frame number
frame_time = (clamped_frame.to_f * @duration) / @num_frames
frame_time = frame_time.clamp(0.0..(@duration * 0.98))
logger.info(
format_tags(
make_tag("frame", frame),
make_tag("frame_time", frame_time),
make_tag("duration", @duration),
make_tag("num_frames", @num_frames),
),
)
# Use the original frame number in the filename
tmp_path =
File.join(BlobFile::TMP_DIR, "webm-#{frame}-#{SecureRandom.uuid}.png")
FileUtils.mkdir_p(File.dirname(tmp_path))
# Always seek from the beginning for simplicity and reliability
# Add a small safety margin for the last frame to avoid seeking beyond video duration
safe_seek_time = [frame_time, @duration - 0.01].min
cmd = [
FFMPEG_BIN,
"-y", # Overwrite output files
"-xerror", # Exit on error
"-abort_on",
"empty_output", # Abort if output is empty
"-ss",
safe_seek_time.round(2).to_s,
"-i",
@media_path, # Input file
"-vframes",
"1", # Extract one frame
"-f",
"image2", # Force format to image2
"-update",
"1", # Update existing file
tmp_path,
]
_output, error, status = Open3.capture3(*cmd)
unless status.exitstatus == 0
$stderr.print error
raise "Failed to extract frame with ffmpeg: #{cmd.join(" ")}: #{error}"
end
# Use the original frame number in the error message
raise("screenshot @ #{clamped_frame} failed") unless File.exist?(tmp_path)
image = Vips::Image.new_from_file(tmp_path)
write_image_thumbnail(image, path, options)
ensure
FileUtils.rm_rf(tmp_path) if tmp_path && File.exist?(tmp_path)
end
end