Stabilizza live YouTube/RTMP e fix crash rotazione mobile.
Pipeline YouTube con relay, overlay e publisher sync lato backend; fix GL pauseGlDuringRotation su Android per evitare crash in rotazione durante lo streaming Flutter. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,92 +4,115 @@ module Streams
|
||||
class Error < StandardError; end
|
||||
|
||||
REDIS_KEY = "overlay_relay:pid:%s"
|
||||
LIVE_INTAKE_KEY = "overlay_relay:live_intake:%s"
|
||||
MODE_KEY = "overlay_relay:mode:%s"
|
||||
START_LOCK_KEY = "overlay_relay:start_lock:%s"
|
||||
MODE_LIVE = "live".freeze
|
||||
MODE_COVER = "cover".freeze
|
||||
|
||||
class << self
|
||||
def start(session)
|
||||
def start(session, mode: nil)
|
||||
return if session.terminal?
|
||||
return unless acquire_start_lock!(session.id)
|
||||
|
||||
stop(session) if pid_for(session.id).present?
|
||||
begin
|
||||
terminate_all_session_processes!(session.id)
|
||||
|
||||
Streams::Overlay::Refresh.call(session)
|
||||
Streams::Overlay::Refresh.call(session)
|
||||
|
||||
dir = Streams::Overlay.overlay_dir(session.id)
|
||||
FileUtils.mkdir_p(dir)
|
||||
png = Streams::Overlay.overlay_png_path(session)
|
||||
pipe = Streams::Overlay.overlay_pipe_path(session)
|
||||
ensure_pipe!(pipe)
|
||||
intake = "#{mediamtx_rtmp_url}/#{session.mediamtx_path_name}"
|
||||
output = "#{mediamtx_rtmp_url}/#{session.mediamtx_overlay_path_name}"
|
||||
log_path = log_file(session)
|
||||
FileUtils.mkdir_p(File.dirname(log_path))
|
||||
dir = Streams::Overlay.overlay_dir(session.id)
|
||||
FileUtils.mkdir_p(dir)
|
||||
png = Streams::Overlay.overlay_png_path(session)
|
||||
pipe = Streams::Overlay.overlay_pipe_path(session)
|
||||
ensure_pipe!(pipe)
|
||||
log_path = log_file(session)
|
||||
FileUtils.mkdir_p(File.dirname(log_path))
|
||||
|
||||
fps = output_fps
|
||||
overlay_fps = overlay_input_fps
|
||||
filter = "[1:v]format=rgba[ol];[0:v]fps=#{fps},format=yuv420p[vid];[vid][ol]overlay=0:0:format=auto[vout]"
|
||||
|
||||
ffmpeg_pid = Process.spawn(
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
"-analyzeduration", "10000000", "-probesize", "10000000",
|
||||
"-rw_timeout", "15000000",
|
||||
"-i", intake,
|
||||
"-f", "image2pipe", "-framerate", overlay_fps.to_s, "-i", pipe.to_s,
|
||||
"-filter_complex", filter,
|
||||
"-map", "[vout]", "-map", "0:a?",
|
||||
"-fps_mode", "cfr", "-r", fps.to_s,
|
||||
"-c:v", "libx264", "-preset", x264_preset, "-bf", "0",
|
||||
"-profile:v", "high", "-pix_fmt", "yuv420p",
|
||||
"-b:v", bitrate_for(session), "-maxrate", maxrate_for(session), "-bufsize", bufsize_for(session),
|
||||
"-g", fps.to_s, "-keyint_min", fps.to_s,
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
||||
"-f", "flv", output,
|
||||
%i[out err] => log_path,
|
||||
pgroup: true
|
||||
)
|
||||
spawn_png_feeder!(ffmpeg_pid, pipe, png, log_path)
|
||||
Process.detach(ffmpeg_pid)
|
||||
store_pid(session.id, ffmpeg_pid)
|
||||
OverlayRefreshJob.schedule_chain(session.id)
|
||||
Rails.logger.info("[OverlayRelay] started pid=#{ffmpeg_pid} session=#{session.id}")
|
||||
ffmpeg_pid
|
||||
mode = normalize_mode(mode || desired_mode(session))
|
||||
ffmpeg_pid = Process.spawn(*ffmpeg_command(session, pipe, mode), %i[out err] => log_path, pgroup: true)
|
||||
spawn_png_feeder!(ffmpeg_pid, pipe, png, log_path)
|
||||
Process.detach(ffmpeg_pid)
|
||||
store_pid(session.id, ffmpeg_pid)
|
||||
store_mode(session.id, mode)
|
||||
mode == MODE_LIVE ? mark_live_intake!(session.id) : clear_live_intake!(session.id)
|
||||
OverlayRefreshJob.schedule_chain(session.id)
|
||||
if session.platform == "youtube" && session.stream_key.present?
|
||||
Streams::YoutubeRelay.stop(session)
|
||||
Youtube::LivePipeline.schedule_activate!(session, force: true, wait: 20.seconds)
|
||||
end
|
||||
Rails.logger.info("[OverlayRelay] started pid=#{ffmpeg_pid} mode=#{mode} session=#{session.id} youtube_tee=#{session.platform == "youtube"}")
|
||||
ffmpeg_pid
|
||||
ensure
|
||||
release_start_lock!(session.id)
|
||||
end
|
||||
rescue Errno::ENOENT => e
|
||||
raise Error, "ffmpeg non disponibile: #{e.message}"
|
||||
end
|
||||
|
||||
def stop(session)
|
||||
pid = pid_for(session.id)
|
||||
return false if pid.blank?
|
||||
pids = session_process_pids(session.id)
|
||||
redis_pid = pid_for(session.id).to_i
|
||||
pids << redis_pid if redis_pid > 1
|
||||
pids = pids.uniq.select { |p| p > 1 }
|
||||
return false if pids.empty?
|
||||
|
||||
terminate_pid(pid)
|
||||
pids.each { |p| terminate_pid(p) }
|
||||
clear_pid(session.id)
|
||||
clear_mode(session.id)
|
||||
clear_live_intake!(session.id)
|
||||
pipe = Streams::Overlay.overlay_pipe_path(session)
|
||||
FileUtils.rm_f(pipe) if pipe.exist?
|
||||
OverlayRefreshJob.cancel_chain(session.id)
|
||||
Rails.logger.info("[OverlayRelay] stopped pid=#{pid} session=#{session.id}")
|
||||
Rails.logger.info("[OverlayRelay] stopped pids=#{pids.join(",")} session=#{session.id}")
|
||||
true
|
||||
end
|
||||
|
||||
def running?(session_id)
|
||||
pid = pid_for(session_id)
|
||||
return false if pid.blank?
|
||||
return false unless overlay_relay_process_check_local?
|
||||
|
||||
return true unless overlay_relay_process_check_local?
|
||||
alive = session_process_pids(session_id).select { |p| process_alive?(p) }
|
||||
return true if alive.any?
|
||||
|
||||
process_alive?(pid)
|
||||
redis_pid = pid_for(session_id).to_i
|
||||
return false if redis_pid <= 1
|
||||
|
||||
process_alive?(redis_pid)
|
||||
end
|
||||
|
||||
# Overlay ffmpeg vivo e path _air riceve dati.
|
||||
def healthy?(session)
|
||||
running?(session.id) && overlay_path_publishing?(session)
|
||||
end
|
||||
|
||||
# Riavvia il relay se il processo è morto o _air non pubblica (es. feeder bloccato).
|
||||
def ensure_publishing!(session)
|
||||
return if session.terminal?
|
||||
restart_if_missing_youtube_tee!(session)
|
||||
wanted_mode = desired_mode(session)
|
||||
pids = session_process_pids(session.id)
|
||||
if pids.any? && pids.none? { |p| process_alive?(p) }
|
||||
terminate_all_session_processes!(session.id)
|
||||
end
|
||||
if healthy?(session) && current_mode(session.id) == wanted_mode
|
||||
Youtube::LivePipeline.schedule_activate!(session) if session.platform == "youtube"
|
||||
return
|
||||
end
|
||||
|
||||
if running?(session.id) && overlay_path_publishing?(session)
|
||||
if running?(session.id) && current_mode(session.id) != wanted_mode
|
||||
return unless mode_switch_allowed?(session.id, wanted_mode)
|
||||
|
||||
Rails.logger.info("[OverlayRelay] switch mode #{current_mode(session.id)}→#{wanted_mode} session=#{session.id}")
|
||||
stop(session)
|
||||
end
|
||||
|
||||
if running?(session.id) && overlay_path_publishing?(session) && current_mode(session.id) == wanted_mode
|
||||
return
|
||||
end
|
||||
|
||||
if running?(session.id)
|
||||
misses = redis.incr(overlay_air_miss_key(session.id)).to_i
|
||||
redis.expire(overlay_air_miss_key(session.id), 120)
|
||||
return if misses < 3
|
||||
return if misses < 4
|
||||
|
||||
redis.del(overlay_air_miss_key(session.id))
|
||||
Rails.logger.warn("[OverlayRelay] _air non attivo dopo #{misses} tentativi, restart session=#{session.id}")
|
||||
@@ -98,24 +121,156 @@ module Streams
|
||||
redis.del(overlay_air_miss_key(session.id))
|
||||
end
|
||||
|
||||
start(session)
|
||||
start(session, mode: wanted_mode)
|
||||
rescue Error => e
|
||||
Rails.logger.warn("[OverlayRelay] ensure_publishing session=#{session.id}: #{e.message}")
|
||||
end
|
||||
|
||||
def desired_mode(session)
|
||||
return MODE_COVER if session.paused?
|
||||
return MODE_LIVE if Mediamtx::PublisherOnline.video_publishing?(session)
|
||||
# RTMP a tratti: resta su live finché c'era segnale telefono (evita flip copertina↔live).
|
||||
return MODE_LIVE if session.reconnecting? && live_intake?(session.id)
|
||||
return MODE_LIVE if session.status.in?(%w[live connecting reconnecting]) && recent_phone_bytes?(session)
|
||||
|
||||
MODE_COVER
|
||||
end
|
||||
|
||||
def terminate_all_session_processes!(session_id)
|
||||
session_process_pids(session_id).each do |pid|
|
||||
terminate_pid(pid)
|
||||
end
|
||||
redis_pid = pid_for(session_id).to_i
|
||||
terminate_pid(redis_pid) if redis_pid > 1
|
||||
clear_pid(session_id)
|
||||
clear_mode(session_id)
|
||||
end
|
||||
|
||||
def session_process_pids(session_id)
|
||||
tag = session_match_tag(session_id)
|
||||
pids = []
|
||||
each_overlay_process do |pid, cmdline|
|
||||
pids << pid if cmdline.include?(tag)
|
||||
end
|
||||
pids.uniq
|
||||
end
|
||||
|
||||
def session_match_tag(session_id)
|
||||
"match_#{session_id}"
|
||||
end
|
||||
|
||||
def each_overlay_process
|
||||
return unless overlay_relay_process_check_local?
|
||||
|
||||
Dir.glob("/proc/[0-9]*").each do |proc_dir|
|
||||
pid = proc_dir.split("/").last.to_i
|
||||
next if pid <= 1
|
||||
|
||||
cmdline = File.read("#{proc_dir}/cmdline").tr("\0", " ")
|
||||
next unless cmdline.include?("ffmpeg") || cmdline.include?("overlay_png_feeder")
|
||||
next unless cmdline.include?("match_")
|
||||
|
||||
yield pid, cmdline
|
||||
rescue Errno::ENOENT, Errno::EPERM
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mediamtx_rtmp_url
|
||||
ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935").chomp("/")
|
||||
end
|
||||
|
||||
def ffmpeg_command(session, pipe, mode)
|
||||
mode == MODE_LIVE ? live_command(session, pipe) : cover_command(session, pipe)
|
||||
end
|
||||
|
||||
def live_command(session, pipe)
|
||||
intake = "#{mediamtx_rtmp_url}/#{session.mediamtx_path_name}"
|
||||
[
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
"-analyzeduration", "20000000", "-probesize", "20000000",
|
||||
"-rw_timeout", "15000000",
|
||||
"-noautorotate",
|
||||
"-i", intake,
|
||||
"-thread_queue_size", "512",
|
||||
"-f", "image2pipe", "-framerate", overlay_input_fps.to_s, "-i", pipe.to_s,
|
||||
*common_encode_args(session, live_filter, "0:a?")
|
||||
]
|
||||
end
|
||||
|
||||
def cover_command(session, pipe)
|
||||
cover = cover_image_path
|
||||
raise Error, "copertina fallback non trovata: #{cover}" unless File.exist?(cover)
|
||||
|
||||
[
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||
"-re", "-loop", "1", "-framerate", output_fps.to_s, "-i", cover,
|
||||
"-thread_queue_size", "512",
|
||||
"-f", "image2pipe", "-framerate", overlay_input_fps.to_s, "-i", pipe.to_s,
|
||||
"-f", "lavfi", "-i", "anullsrc=channel_layout=mono:sample_rate=48000",
|
||||
*common_encode_args(session, cover_filter, "2:a")
|
||||
]
|
||||
end
|
||||
|
||||
def common_encode_args(session, filter, audio_map)
|
||||
fps = output_fps
|
||||
[
|
||||
"-filter_complex", filter,
|
||||
"-map", "[vout]", "-map", audio_map,
|
||||
"-fps_mode", "cfr", "-r", fps.to_s,
|
||||
"-c:v", "libx264", "-preset", x264_preset, "-bf", "0",
|
||||
"-profile:v", "high", "-pix_fmt", "yuv420p",
|
||||
"-b:v", bitrate_for(session), "-maxrate", maxrate_for(session), "-bufsize", bufsize_for(session),
|
||||
"-g", fps.to_s, "-keyint_min", fps.to_s,
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
||||
*output_args(session)
|
||||
]
|
||||
end
|
||||
|
||||
# Crop center 16:9 a tutto schermo (niente bande nere su TV/web).
|
||||
def live_filter
|
||||
fps = output_fps
|
||||
"[1:v]format=rgba[ol];" \
|
||||
"[0:v]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,setsar=1,fps=#{fps},format=yuv420p[vid];" \
|
||||
"[vid][ol]overlay=0:0:format=auto[vout]"
|
||||
end
|
||||
|
||||
def cover_filter
|
||||
fps = output_fps
|
||||
"[1:v]format=rgba[ol];" \
|
||||
"[0:v]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,setsar=1,fps=#{fps},format=yuv420p[vid];" \
|
||||
"[vid][ol]overlay=0:0:format=auto[vout]"
|
||||
end
|
||||
|
||||
def cover_image_path
|
||||
ENV.fetch(
|
||||
"OVERLAY_COVER_IMAGE",
|
||||
Rails.root.join("branding", "CopertinaCanale_6.png").to_s
|
||||
)
|
||||
end
|
||||
|
||||
# Un solo encode → MediaMTX _air + YouTube RTMPS (evita seconda lettura RTMP senza SPS).
|
||||
def output_args(session)
|
||||
air = "#{mediamtx_rtmp_url}/#{session.mediamtx_overlay_path_name}"
|
||||
if session.platform == "youtube" && session.stream_key.present?
|
||||
yt = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
|
||||
tee = "[f=flv:onfail=ignore]#{air}|[f=flv:onfail=ignore:use_fifo=1]#{yt}"
|
||||
["-f", "tee", tee]
|
||||
else
|
||||
["-f", "flv", air]
|
||||
end
|
||||
end
|
||||
|
||||
# Ricodifica con overlay: serve più budget della sorgente (generational loss).
|
||||
def output_video_kbps(session)
|
||||
override = ENV["OVERLAY_RELAY_VIDEO_KBPS"].presence&.to_i
|
||||
return override if override&.positive?
|
||||
|
||||
source_kbps = [(session.target_bitrate.to_i / 1000), 2500].max
|
||||
[(source_kbps * 1.8).to_i, 4500].max
|
||||
source_kbps = [(session.target_bitrate.to_i / 1000), 3000].max
|
||||
[(source_kbps * 2.0).to_i, 5500].max
|
||||
end
|
||||
|
||||
def bitrate_for(session)
|
||||
@@ -166,7 +321,52 @@ module Streams
|
||||
|
||||
def overlay_path_publishing?(session)
|
||||
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_overlay_path_name }
|
||||
info && (info["online"] || info["ready"] || info["available"])
|
||||
info && info["online"] && info["bytesReceived"].to_i > 100_000
|
||||
end
|
||||
|
||||
def acquire_start_lock!(session_id)
|
||||
redis.set(format(START_LOCK_KEY, session_id), "1", nx: true, ex: 30)
|
||||
end
|
||||
|
||||
def release_start_lock!(session_id)
|
||||
redis.del(format(START_LOCK_KEY, session_id))
|
||||
end
|
||||
|
||||
def publisher_has_video?(session)
|
||||
info = Mediamtx::PublisherOnline.path_info(session)
|
||||
return false unless Mediamtx::PublisherOnline.active_path?(info)
|
||||
|
||||
(info["tracks2"] || []).any? do |track|
|
||||
track["codec"] == "H264" && track.dig("codecProps", "width").to_i.positive?
|
||||
end
|
||||
end
|
||||
|
||||
def live_intake?(session_id)
|
||||
redis.get(format(LIVE_INTAKE_KEY, session_id)) == "1"
|
||||
end
|
||||
|
||||
def current_mode(session_id)
|
||||
redis.get(format(MODE_KEY, session_id))
|
||||
end
|
||||
|
||||
def normalize_mode(mode)
|
||||
mode == MODE_LIVE ? MODE_LIVE : MODE_COVER
|
||||
end
|
||||
|
||||
def mark_live_intake!(session_id)
|
||||
redis.set(format(LIVE_INTAKE_KEY, session_id), "1", ex: 48.hours.to_i)
|
||||
end
|
||||
|
||||
def clear_live_intake!(session_id)
|
||||
redis.del(format(LIVE_INTAKE_KEY, session_id))
|
||||
end
|
||||
|
||||
def store_mode(session_id, mode)
|
||||
redis.set(format(MODE_KEY, session_id), normalize_mode(mode), ex: 48.hours.to_i)
|
||||
end
|
||||
|
||||
def clear_mode(session_id)
|
||||
redis.del(format(MODE_KEY, session_id))
|
||||
end
|
||||
|
||||
def log_file(session)
|
||||
@@ -193,6 +393,46 @@ module Streams
|
||||
format("overlay_relay:air_miss:%s", session_id)
|
||||
end
|
||||
|
||||
def recent_phone_bytes?(session)
|
||||
info = Mediamtx::PublisherOnline.path_info(session)
|
||||
info && info["bytesReceived"].to_i > 1_000_000
|
||||
end
|
||||
|
||||
def publishing_to_youtube?(session)
|
||||
return false unless session.platform == "youtube" && session.stream_key.present?
|
||||
|
||||
session_process_pids(session.id).any? do |pid|
|
||||
cmdline = File.read("/proc/#{pid}/cmdline").tr("\0", " ")
|
||||
cmdline.include?("rtmps://") || cmdline.include?("youtube.com/live2")
|
||||
rescue Errno::ENOENT, Errno::EPERM
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def restart_if_missing_youtube_tee!(session)
|
||||
return unless session.platform == "youtube" && session.stream_key.present?
|
||||
return unless running?(session.id)
|
||||
return if publishing_to_youtube?(session)
|
||||
|
||||
Rails.logger.warn("[OverlayRelay] restart overlay: manca tee YouTube session=#{session.id}")
|
||||
stop(session)
|
||||
end
|
||||
|
||||
def mode_switch_allowed?(session_id, wanted_mode)
|
||||
key = format("overlay_relay:mode_want:%s", session_id)
|
||||
prev = redis.get(key)
|
||||
redis.set(key, wanted_mode, ex: 120)
|
||||
return true if prev.blank? || prev == wanted_mode
|
||||
|
||||
ticks = redis.incr(format("overlay_relay:mode_ticks:%s", session_id)).to_i
|
||||
redis.expire(format("overlay_relay:mode_ticks:%s", session_id), 60)
|
||||
if ticks >= 3
|
||||
redis.del(format("overlay_relay:mode_ticks:%s", session_id))
|
||||
return true
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
def process_alive?(pid)
|
||||
stat = File.read("/proc/#{pid.to_i}/stat")
|
||||
return false if stat.split[2] == "Z"
|
||||
|
||||
Reference in New Issue
Block a user