Sposta overlay video sull'app Android e rimuove ffmpeg server-side.

Il tabellone stile SportCam e il watermark Match Live TV sono bruciati in GPU via RootEncoder; sul backend spariscono OverlayRelay, job Sidekiq e volume stream_overlays.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-06 18:12:35 +02:00
parent 68b4390282
commit f3ff657fc2
29 changed files with 605 additions and 975 deletions

View File

@@ -1,46 +0,0 @@
module Streams
module Overlay
module_function
def overlay_dir(session_id)
Rails.root.join("tmp/stream_overlays", session_id.to_s)
end
def overlay_png_path(session)
overlay_dir(session.id).join("overlay.png")
end
def overlay_pipe_path(session)
overlay_dir(session.id).join("overlay.pipe")
end
# Logo chiaro watermark (brand/logo-white-m.png → branding/ in deploy).
def brand_logo_path
candidates = [
Rails.root.join("branding/logo-white-m.png"),
Rails.root.join("brand/logo-white-m.png"),
Rails.root.join("public/logo-white.png"),
Rails.root.join("public/logo.png")
]
candidates.find(&:file?) || candidates.first
end
def badge_away_color(team)
club = team.club
candidate = club&.effective_secondary_color.presence || "#1565c0"
return candidate unless light_hex_color?(candidate)
"#1565c0"
end
def light_hex_color?(hex)
h = hex.to_s.delete_prefix("#")
return false unless h.match?(/\A\h{6}\z/i)
r = h[0..1].to_i(16)
g = h[2..3].to_i(16)
b = h[4..5].to_i(16)
(0.299 * r + 0.587 * g + 0.114 * b) > 200
end
end
end

View File

@@ -1,49 +0,0 @@
module Streams
module Overlay
# Etichetta stato broadcast (stessa logica della pagina live / badge HTML).
class BadgeLabel
Result = Struct.new(:text, :background, :foreground, keyword_init: true)
def self.for(session)
new(session).call
end
def initialize(session)
@session = session
end
def call
return Result.new(text: "DIRETTA CHIUSA", background: "#374151", foreground: "#dddddd") if @session.terminal?
return Result.new(text: "IN PAUSA", background: "#455a64", foreground: "#ffffff") if @session.paused?
if Mediamtx::PublisherOnline.active?(@session) || (@session.live? && !@session.paused?)
return Result.new(text: "IN ONDA", background: "#2e7d32", foreground: "#ffffff")
end
if path_has_output?
if @session.reconnecting?
return Result.new(text: "RICONNESSIONE", background: "#f57c00", foreground: "#ffffff")
end
if @session.connecting?
return Result.new(text: "CONNECTING", background: "#f57c00", foreground: "#ffffff")
end
# Slate alwaysAvailable senza telefono: non bruciare «COPERTINA» su YouTube.
return Result.new(text: "IN ATTESA", background: "#455a64", foreground: "#ffffff")
end
Result.new(text: "IN ATTESA", background: "#455a64", foreground: "#ffffff")
end
private
def path_info
@path_info ||= Mediamtx::PublisherOnline.path_info(@session)
end
def path_has_output?
info = path_info
info && (info["ready"] || info["available"] || info["online"])
end
end
end
end

View File

@@ -1,50 +0,0 @@
module Streams
module Overlay
class Refresh
class Error < StandardError; end
def self.call(session)
new(session).call
end
def initialize(session)
@session = session.reload
end
def call
return false if @session.terminal?
dir = Streams::Overlay.overlay_dir(@session.id)
FileUtils.mkdir_p(dir)
badge = BadgeLabel.for(@session)
svg = SvgBuilder.new(@session, badge: badge).to_svg
svg_path = dir.join("overlay.svg")
png_path = Streams::Overlay.overlay_png_path(@session)
atomic_write(svg_path, svg)
render_png!(svg_path, png_path)
true
rescue StandardError => e
Rails.logger.warn("[Overlay::Refresh] session=#{@session.id} #{e.class}: #{e.message}")
false
end
private
def atomic_write(path, content)
tmp = "#{path}.tmp"
File.write(tmp, content)
File.rename(tmp, path)
end
def render_png!(svg_path, png_path)
tmp = "#{png_path}.tmp"
unless system("rsvg-convert", "-w", "1280", "-h", "720", "-o", tmp, svg_path.to_s, out: File::NULL, err: File::NULL)
raise Error, "rsvg-convert fallito per session #{@session.id}"
end
File.rename(tmp, png_path)
FileUtils.touch(png_path)
end
end
end
end

View File

@@ -1,162 +0,0 @@
module Streams
module Overlay
# PNG 1280×720 trasparente: tabellone alto-sinistra, badge alto-destra, logo chiaro (logo-white-m) basso-destra.
class SvgBuilder
CANVAS_W = 1280
CANVAS_H = 720
def initialize(session, badge:)
@session = session
@match = session.match
@team = @match.team
@score = session.score_state
@badge = badge
end
def to_svg
cols = score_columns
<<~SVG
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="#{CANVAS_W}" height="#{CANVAS_H}" viewBox="0 0 #{CANVAS_W} #{CANVAS_H}">
#{scorebug_svg(cols)}
#{badge_svg}
#{brand_watermark_svg}
</svg>
SVG
end
private
def score_columns
score = @score
unless score
return {
labels: ["1"], home: ["0"], away: ["0"], home_name: abbrev(@match.team.name),
away_name: abbrev(@match.opponent_name), sets_won: "0-0", current_set: 1
}
end
partials = Array(score.set_partials)
labels = partials.map { |p| p["set"].to_s }
home = partials.map { |p| p["home"].to_s }
away = partials.map { |p| p["away"].to_s }
labels << score.current_set.to_s
home << score.home_points.to_s
away << score.away_points.to_s
{
labels: labels,
home: home,
away: away,
home_name: abbrev(@match.team.name),
away_name: abbrev(@match.opponent_name),
sets_won: "#{score.home_sets}-#{score.away_sets}",
current_set: score.current_set
}
end
def scorebug_svg(cols)
x0 = 12
y0 = 12
col_w = 26
team_w = 118
row_h = 18
head_h = 16
pad = 8
n = cols[:labels].length
box_w = team_w + n * col_w + pad * 2
box_h = head_h + row_h * 2 + 22 + pad * 2
live_i = n - 1
home_primary = @team.effective_primary_color
away_primary = Overlay.badge_away_color(@team)
header_cells = cols[:labels].map.with_index do |label, i|
cx = x0 + pad + team_w + i * col_w + col_w / 2
"<text x=\"#{cx}\" y=\"#{y0 + pad + 12}\" text-anchor=\"middle\" font-family=\"DejaVu Sans, sans-serif\" font-size=\"10\" font-weight=\"700\" fill=\"#666666\">#{escape(label)}</text>"
end.join
home_pts = cols[:home].map.with_index do |val, i|
cx = x0 + pad + team_w + i * col_w + col_w / 2
y = y0 + pad + head_h + 14
fill = i == live_i ? home_primary : "#111111"
weight = i == live_i ? "900" : "800"
bg = i == live_i ? "<rect x=\"#{cx - 12}\" y=\"#{y - 13}\" width=\"24\" height=\"16\" rx=\"3\" fill=\"#{home_primary}\" fill-opacity=\"0.14\"/>" : ""
"#{bg}<text x=\"#{cx}\" y=\"#{y}\" text-anchor=\"middle\" font-family=\"DejaVu Sans, sans-serif\" font-size=\"13\" font-weight=\"#{weight}\" fill=\"#{fill}\">#{escape(val)}</text>"
end.join
away_pts = cols[:away].map.with_index do |val, i|
cx = x0 + pad + team_w + i * col_w + col_w / 2
y = y0 + pad + head_h + row_h + 14
fill = i == live_i ? away_primary : "#111111"
weight = i == live_i ? "900" : "800"
bg = i == live_i ? "<rect x=\"#{cx - 12}\" y=\"#{y - 13}\" width=\"24\" height=\"16\" rx=\"3\" fill=\"#{away_primary}\" fill-opacity=\"0.14\"/>" : ""
"#{bg}<text x=\"#{cx}\" y=\"#{y}\" text-anchor=\"middle\" font-family=\"DejaVu Sans, sans-serif\" font-size=\"13\" font-weight=\"#{weight}\" fill=\"#{fill}\">#{escape(val)}</text>"
end.join
<<~SVG
<g id="scorebug">
<rect x="#{x0}" y="#{y0}" width="#{box_w}" height="#{box_h}" rx="6" fill="#ffffff" fill-opacity="0.94"/>
<line x1="#{x0 + pad}" y1="#{y0 + pad + head_h}" x2="#{x0 + box_w - pad}" y2="#{y0 + pad + head_h}" stroke="#000000" stroke-opacity="0.12"/>
#{header_cells}
<text x="#{x0 + pad}" y="#{y0 + pad + head_h + 14}" font-family="DejaVu Sans, sans-serif" font-size="12" font-weight="700" fill="#{home_primary}">#{escape(cols[:home_name])}</text>
#{home_pts}
<text x="#{x0 + pad}" y="#{y0 + pad + head_h + row_h + 14}" font-family="DejaVu Sans, sans-serif" font-size="12" font-weight="700" fill="#{away_primary}">#{escape(cols[:away_name])}</text>
#{away_pts}
<rect x="#{x0 + pad}" y="#{y0 + box_h - pad - 18}" width="#{box_w - pad * 2}" height="16" rx="4" fill="url(#brandGrad)"/>
<text x="#{x0 + box_w / 2}" y="#{y0 + box_h - pad - 6}" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="9" font-weight="800" fill="#ffffff" letter-spacing="1.2">MATCH <tspan fill="#e53935">LIVE TV</tspan></text>
</g>
<defs>
<linearGradient id="brandGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#14141c"/>
<stop offset="55%" stop-color="#2a1214"/>
<stop offset="100%" stop-color="#1a1a24"/>
</linearGradient>
</defs>
SVG
end
def brand_watermark_svg
path = Overlay.brand_logo_path
return "" unless path.file?
w = 108
h = 108
x = CANVAS_W - 14 - w
y = CANVAS_H - 14 - h
uri = "file://#{path.expand_path}"
<<~SVG
<g id="brand-watermark" opacity="0.94">
<image xlink:href="#{escape(uri)}" x="#{x}" y="#{y}" width="#{w}" height="#{h}" preserveAspectRatio="xMidYMid meet"/>
</g>
SVG
end
def badge_svg
text = @badge.text
bg = @badge.background
fg = @badge.foreground
pad_x = 12
pad_y = 6
fs = 11
tw = text.length * 6.5 + pad_x * 2
th = fs + pad_y * 2
x = CANVAS_W - 12 - tw
y = 12
<<~SVG
<g id="badge">
<rect x="#{x}" y="#{y}" width="#{tw}" height="#{th}" rx="#{th / 2}" fill="#{bg}"/>
<text x="#{x + tw / 2}" y="#{y + th - pad_y - 1}" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="#{fs}" font-weight="700" fill="#{fg}" letter-spacing="0.5">#{escape(text)}</text>
</g>
SVG
end
def abbrev(name, max = 16)
n = name.to_s
n.length <= max ? n : "#{n[0, max - 1]}…"
end
def escape(str)
str.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
end
end
end
end

View File

@@ -1,460 +0,0 @@
module Streams
# Transcodifica path grezzo → path _air con overlay PNG (tabellone + badge) bruciato nel video.
class OverlayRelay
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, mode: nil)
return if session.terminal?
return unless acquire_start_lock!(session.id)
begin
terminate_all_session_processes!(session.id)
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)
log_path = log_file(session)
FileUtils.mkdir_p(File.dirname(log_path))
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)
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?
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 pids=#{pids.join(",")} session=#{session.id}")
true
end
def running?(session_id)
return false unless overlay_relay_process_check_local?
alive = session_process_pids(session_id).select { |p| process_alive?(p) }
return true if alive.any?
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) && 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 < 4
redis.del(overlay_air_miss_key(session.id))
Rails.logger.warn("[OverlayRelay] _air non attivo dopo #{misses} tentativi, restart session=#{session.id}")
stop(session)
else
redis.del(overlay_air_miss_key(session.id))
end
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), 3000].max
[(source_kbps * 2.0).to_i, 5500].max
end
def bitrate_for(session)
"#{output_video_kbps(session)}k"
end
def maxrate_for(session)
kbps = (output_video_kbps(session) * 1.15).to_i
"#{kbps}k"
end
def bufsize_for(session)
kbps = output_video_kbps(session) * 2
"#{kbps}k"
end
def output_fps
ENV.fetch("OVERLAY_RELAY_FPS", "30").to_i
end
def x264_preset
ENV.fetch("OVERLAY_RELAY_X264_PRESET", "fast")
end
def overlay_input_fps
ENV.fetch("OVERLAY_INPUT_FPS", "2").to_i
end
def ensure_pipe!(path)
FileUtils.rm_f(path) if path.exist? && !path.pipe?
system("mkfifo", path.to_s) unless path.exist?
raise Error, "impossibile creare fifo overlay #{path}" unless path.pipe?
end
def spawn_png_feeder!(ffmpeg_pid, pipe, png, log_path)
feeder = Rails.root.join("bin/overlay_png_feeder.rb")
poll = (1.0 / overlay_input_fps).round(2)
Process.spawn(
RbConfig.ruby, feeder.to_s, pipe.to_s, png.to_s, poll.to_s,
%i[out err] => log_path,
pgroup: ffmpeg_pid
)
end
def overlay_relay_process_check_local?
ENV["OVERLAY_RELAY_PROCESS_CHECK"] != "false"
end
def overlay_path_publishing?(session)
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_overlay_path_name }
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)
Rails.root.join("log", "overlay_relay_#{session.id}.log").to_s
end
def redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
def store_pid(session_id, pid)
redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i)
end
def clear_pid(session_id)
redis.del(format(REDIS_KEY, session_id))
end
def pid_for(session_id)
redis.get(format(REDIS_KEY, session_id))
end
def overlay_air_miss_key(session_id)
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"
Process.kill(0, pid.to_i)
true
rescue Errno::ESRCH, Errno::EPERM, Errno::ENOENT
false
end
def terminate_pid(pid)
Process.kill("TERM", -pid.to_i)
sleep 0.5
rescue Errno::ESRCH
nil
else
begin
Process.kill("KILL", -pid.to_i)
rescue Errno::ESRCH
nil
end
end
end
end
end

View File

@@ -116,7 +116,7 @@ module Streams
raise Error, "ffmpeg non disponibile: #{e.message}"
end
# _air RTMP (copy) se pronto; altrimenti HLS via proxy Rails.
# RTMP grezzo da telefono (overlay bruciato lato app); fallback HLS via proxy Rails.
def youtube_ffmpeg_args(intake_source, output)
mode, url = intake_source
if mode == :rtmp