Aggiunge overlay server-side burn-in e stabilizza diretta live.
Tabellone, badge e brand sono ricodificati nel flusso _air via OverlayRelay; sync punteggio HTTP, volume condiviso rails/sidekiq, qualità 720p migliorata e badge CONNECTING in ripresa da pausa. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,28 +12,32 @@ module Mediamtx
|
||||
end
|
||||
end
|
||||
|
||||
def create_path(session)
|
||||
path = session.mediamtx_path_name
|
||||
record = session.match.team.entitlements.recording_enabled_for_mediamtx?
|
||||
def create_overlay_path(session)
|
||||
path = session.mediamtx_overlay_path_name
|
||||
body = {
|
||||
source: "publisher",
|
||||
overridePublisher: true,
|
||||
alwaysAvailable: true,
|
||||
alwaysAvailableFile: slate_file_path,
|
||||
alwaysAvailableTracks: slate_tracks,
|
||||
record: record,
|
||||
recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S",
|
||||
recordSegmentDuration: "60s"
|
||||
record: false
|
||||
}
|
||||
if record
|
||||
retention_hours = session.match.team.entitlements.recording_retention_days * 24 + 48
|
||||
body[:recordDeleteAfter] = "#{retention_hours}h"
|
||||
end
|
||||
unless session.matchlivetv_platform?
|
||||
body[:runOnReady] = run_on_ready_script(session)
|
||||
body[:runOnReadyRestart] = true
|
||||
body[:runOnNotReady] = webhook_curl(session, "disconnect")
|
||||
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
|
||||
unless response.success?
|
||||
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
|
||||
raise Error, "MediaMTX overlay path create failed: #{response.status} #{err}"
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def create_path(session)
|
||||
path = session.mediamtx_path_name
|
||||
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
||||
# solo la slate (schermo nero) in pausa/attesa.
|
||||
body = recording_body(session, enabled: false).merge(
|
||||
source: "publisher",
|
||||
overridePublisher: true,
|
||||
alwaysAvailable: true,
|
||||
alwaysAvailableFile: slate_file_path
|
||||
)
|
||||
# YouTube: relay continuo gestito da Streams::YoutubeRelay (ffmpeg in sidekiq/rails).
|
||||
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
|
||||
unless response.success?
|
||||
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
|
||||
@@ -44,18 +48,29 @@ module Mediamtx
|
||||
|
||||
def delete_path(session)
|
||||
delete_path_name(session.mediamtx_path_name)
|
||||
delete_path_name(session.mediamtx_overlay_path_name)
|
||||
end
|
||||
|
||||
def delete_path_name(path_name)
|
||||
@conn.post("/v3/config/paths/delete/#{CGI.escape(path_name)}")
|
||||
end
|
||||
|
||||
def set_path_recording(session, enabled:)
|
||||
path = session.mediamtx_path_name
|
||||
body = recording_body(session, enabled: enabled)
|
||||
response = @conn.patch("/v3/config/paths/patch/#{CGI.escape(path)}", body)
|
||||
unless response.success?
|
||||
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
|
||||
raise Error, "MediaMTX recording patch failed: #{response.status} #{err}"
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def patch_path(session)
|
||||
path = session.mediamtx_path_name
|
||||
body = {
|
||||
alwaysAvailable: true,
|
||||
alwaysAvailableFile: slate_file_path,
|
||||
alwaysAvailableTracks: slate_tracks
|
||||
alwaysAvailableFile: slate_file_path
|
||||
}
|
||||
response = @conn.patch("/v3/config/paths/patch/#{CGI.escape(path)}", body)
|
||||
unless response.success?
|
||||
@@ -81,6 +96,21 @@ module Mediamtx
|
||||
|
||||
private
|
||||
|
||||
def recording_body(session, enabled:)
|
||||
ent = session.match.team.entitlements
|
||||
can_record = ent.recording_enabled_for_mediamtx?
|
||||
body = {
|
||||
record: enabled && can_record,
|
||||
recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S",
|
||||
recordSegmentDuration: "60s"
|
||||
}
|
||||
if enabled && can_record
|
||||
retention_hours = ent.recording_retention_days * 24 + 48
|
||||
body[:recordDeleteAfter] = "#{retention_hours}h"
|
||||
end
|
||||
body
|
||||
end
|
||||
|
||||
def publish_webhook(session)
|
||||
webhook_curl(session, "connect")
|
||||
end
|
||||
@@ -122,12 +152,5 @@ module Mediamtx
|
||||
def slate_file_path
|
||||
ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4")
|
||||
end
|
||||
|
||||
def slate_tracks
|
||||
[
|
||||
{ codec: "H264" },
|
||||
{ codec: "MPEG4Audio", sampleRate: 48_000, channelCount: 2 }
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
51
backend/app/services/mediamtx/publisher_sync.rb
Normal file
51
backend/app/services/mediamtx/publisher_sync.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
module Mediamtx
|
||||
# MediaMTX in produzione è distroless: runOnReady con wget non funziona.
|
||||
# Sincronizziamo lo stato Rails leggendo l'API paths (poll da /live/:id/status.json).
|
||||
class PublisherSync
|
||||
def initialize(session)
|
||||
@session = session
|
||||
end
|
||||
|
||||
def call
|
||||
return @session if @session.terminal?
|
||||
|
||||
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == @session.mediamtx_path_name }
|
||||
publisher_online = info && info["online"]
|
||||
|
||||
if publisher_online
|
||||
if @session.paused?
|
||||
# RTMP ancora connesso in pausa: non forzare live/reconnect.
|
||||
elsif @session.may_go_live?
|
||||
@session.go_live!
|
||||
Streams::Overlay::Refresh.call(@session.reload)
|
||||
elsif @session.reconnecting? && @session.may_reconnect?
|
||||
@session.reconnect!
|
||||
cancel_timeout
|
||||
Streams::Overlay::Refresh.call(@session.reload)
|
||||
end
|
||||
elsif !@session.paused? && @session.live? && @session.may_lose_connection?
|
||||
@session.lose_connection!
|
||||
schedule_timeout unless @session.paused?
|
||||
end
|
||||
|
||||
@session.reload
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cancel_timeout
|
||||
return if @session.timeout_job_id.blank?
|
||||
|
||||
Sidekiq::ScheduledSet.new.find_job(@session.timeout_job_id)&.delete
|
||||
@session.update!(timeout_job_id: nil)
|
||||
end
|
||||
|
||||
def schedule_timeout
|
||||
job = DisconnectionTimeoutJob.perform_in(
|
||||
MatchLiveTv.reconnect_timeout_seconds.seconds,
|
||||
@session.id
|
||||
)
|
||||
@session.update!(timeout_job_id: job)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -64,8 +64,11 @@ module Recordings
|
||||
@merged_temp_path = File.join(Dir.tmpdir, "replay-#{@session.id}-#{SecureRandom.hex(4)}.mp4")
|
||||
list_path = "#{@merged_temp_path}.txt"
|
||||
File.write(list_path, source_files.map { |f| "file '#{f.gsub("'", "'\\''")}'" }.join("\n"))
|
||||
success = system("ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", @merged_temp_path,
|
||||
out: File::NULL, err: File::NULL)
|
||||
success = system(
|
||||
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path,
|
||||
"-c", "copy", "-movflags", "+faststart", @merged_temp_path,
|
||||
out: File::NULL, err: File::NULL
|
||||
)
|
||||
FileUtils.rm_f(list_path)
|
||||
raise Error, "Impossibile unire i segmenti video" unless success && File.exist?(@merged_temp_path)
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ module Scoring
|
||||
|
||||
def broadcast(score)
|
||||
SessionChannel.broadcast_message(@session, score.as_cable_payload)
|
||||
Streams::Overlay::Refresh.call(@session)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
28
backend/app/services/scoring/sync_state.rb
Normal file
28
backend/app/services/scoring/sync_state.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
module Scoring
|
||||
# Sincronizza lo stato completo del tabellone (app mobile / Action Cable).
|
||||
class SyncState
|
||||
SCORE_KEYS = %w[home_sets away_sets home_points away_points current_set].freeze
|
||||
|
||||
def initialize(session:, payload:)
|
||||
@session = session
|
||||
@payload = payload.stringify_keys
|
||||
end
|
||||
|
||||
def call
|
||||
score = @session.score_state || @session.create_score_state!(
|
||||
home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
|
||||
)
|
||||
|
||||
attrs = {}
|
||||
SCORE_KEYS.each do |key|
|
||||
attrs[key] = @payload[key] if @payload.key?(key)
|
||||
end
|
||||
attrs[:set_partials] = @payload["set_partials"] if @payload.key?("set_partials")
|
||||
|
||||
score.update!(attrs)
|
||||
SessionChannel.broadcast_message(@session, score.as_cable_payload)
|
||||
Streams::Overlay::Refresh.call(@session)
|
||||
score
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -36,7 +36,11 @@ module Sessions
|
||||
StreamSession.transaction do
|
||||
session.save!
|
||||
session.create_score_state!
|
||||
Mediamtx::Client.new.create_path(session)
|
||||
mtx = Mediamtx::Client.new
|
||||
mtx.create_path(session)
|
||||
mtx.create_overlay_path(session)
|
||||
start_overlay_relay(session)
|
||||
start_youtube_relay(session)
|
||||
log_event(session, "pairing", { created: true, platform: session.platform })
|
||||
end
|
||||
|
||||
@@ -70,6 +74,20 @@ module Sessions
|
||||
session.rtmp_url = broadcast[:rtmp_url]
|
||||
end
|
||||
|
||||
def start_overlay_relay(session)
|
||||
Streams::OverlayRelay.start(session)
|
||||
rescue Streams::OverlayRelay::Error => e
|
||||
Rails.logger.warn("[Sessions::Create] Overlay relay: #{e.message}")
|
||||
end
|
||||
|
||||
def start_youtube_relay(session)
|
||||
return unless session.platform == "youtube"
|
||||
|
||||
Streams::YoutubeRelay.start(session)
|
||||
rescue Streams::YoutubeRelay::Error => e
|
||||
Rails.logger.warn("[Sessions::Create] YouTube relay: #{e.message}")
|
||||
end
|
||||
|
||||
def log_event(session, type, metadata)
|
||||
session.stream_events.create!(event_type: type, metadata: metadata, occurred_at: Time.current)
|
||||
end
|
||||
|
||||
@@ -7,10 +7,11 @@ module Sessions
|
||||
def call
|
||||
cancel_timeout_job
|
||||
@session.pause! if @session.may_pause?
|
||||
ensure_slate_on_path
|
||||
# Slate già su path (create_path + alwaysAvailable): stop RTMP basta, senza patch API.
|
||||
log_event("paused")
|
||||
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
||||
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "paused" })
|
||||
Streams::Overlay::Refresh.call(@session)
|
||||
@session
|
||||
end
|
||||
|
||||
@@ -23,12 +24,6 @@ module Sessions
|
||||
@session.update!(timeout_job_id: nil)
|
||||
end
|
||||
|
||||
def ensure_slate_on_path
|
||||
Mediamtx::Client.new.patch_path(@session)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[Sessions::Pause] slate path patch failed: #{e.message}")
|
||||
end
|
||||
|
||||
def log_event(type)
|
||||
@session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {})
|
||||
end
|
||||
|
||||
@@ -10,9 +10,13 @@ module Sessions
|
||||
end
|
||||
|
||||
cancel_timeout_job
|
||||
# connecting finché RTMP non è online (evita lose_connection da PublisherSync)
|
||||
@session.begin_connect! if @session.may_begin_connect?
|
||||
# Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher).
|
||||
log_event("resumed")
|
||||
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
|
||||
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "resumed" })
|
||||
Streams::Overlay::Refresh.call(@session)
|
||||
@session
|
||||
end
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ module Sessions
|
||||
|
||||
def call
|
||||
cancel_timeout_job
|
||||
Streams::OverlayRelay.stop(@session)
|
||||
Streams::YoutubeRelay.stop(@session)
|
||||
Sessions::RegiaAccess.revoke!(@session)
|
||||
@session.end_stream!
|
||||
complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
|
||||
|
||||
35
backend/app/services/streams/overlay.rb
Normal file
35
backend/app/services/streams/overlay.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
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
|
||||
|
||||
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
|
||||
53
backend/app/services/streams/overlay/badge_label.rb
Normal file
53
backend/app/services/streams/overlay/badge_label.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
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 publisher_online? || (@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
|
||||
return Result.new(text: "COPERTINA", background: "#455a64", foreground: "#ffffff")
|
||||
end
|
||||
|
||||
Result.new(text: "IN ATTESA", background: "#455a64", foreground: "#ffffff")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def path_info
|
||||
@path_info ||= Mediamtx::Client.new.list_paths.find { |i| i["name"] == @session.mediamtx_path_name }
|
||||
end
|
||||
|
||||
def publisher_online?
|
||||
info = path_info
|
||||
info && info["online"]
|
||||
end
|
||||
|
||||
def path_has_output?
|
||||
info = path_info
|
||||
info && (info["ready"] || info["available"] || info["online"])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
48
backend/app/services/streams/overlay/refresh.rb
Normal file
48
backend/app/services/streams/overlay/refresh.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
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)
|
||||
unless system("rsvg-convert", "-w", "1280", "-h", "720", "-o", png_path.to_s, svg_path.to_s, out: File::NULL, err: File::NULL)
|
||||
raise Error, "rsvg-convert fallito per session #{@session.id}"
|
||||
end
|
||||
|
||||
FileUtils.touch(png_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
145
backend/app/services/streams/overlay/svg_builder.rb
Normal file
145
backend/app/services/streams/overlay/svg_builder.rb
Normal file
@@ -0,0 +1,145 @@
|
||||
module Streams
|
||||
module Overlay
|
||||
# PNG 1280×720 trasparente: tabellone alto-sinistra, badge alto-destra, brand sotto tabellone.
|
||||
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" width="#{CANVAS_W}" height="#{CANVAS_H}" viewBox="0 0 #{CANVAS_W} #{CANVAS_H}">
|
||||
#{scorebug_svg(cols)}
|
||||
#{badge_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 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("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
205
backend/app/services/streams/overlay_relay.rb
Normal file
205
backend/app/services/streams/overlay_relay.rb
Normal file
@@ -0,0 +1,205 @@
|
||||
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"
|
||||
|
||||
class << self
|
||||
def start(session)
|
||||
return if session.terminal?
|
||||
|
||||
stop(session) if pid_for(session.id).present?
|
||||
|
||||
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))
|
||||
|
||||
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",
|
||||
"-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
|
||||
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?
|
||||
|
||||
terminate_pid(pid)
|
||||
clear_pid(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}")
|
||||
true
|
||||
end
|
||||
|
||||
def running?(session_id)
|
||||
pid = pid_for(session_id)
|
||||
return false if pid.blank?
|
||||
|
||||
return true unless overlay_relay_process_check_local?
|
||||
|
||||
process_alive?(pid)
|
||||
end
|
||||
|
||||
# Riavvia il relay se il processo è morto o _air non pubblica (es. feeder bloccato).
|
||||
def ensure_publishing!(session)
|
||||
return if session.terminal?
|
||||
|
||||
if running?(session.id) && overlay_path_publishing?(session)
|
||||
return
|
||||
end
|
||||
|
||||
if running?(session.id)
|
||||
Rails.logger.warn("[OverlayRelay] _air non attivo, restart session=#{session.id}")
|
||||
stop(session)
|
||||
end
|
||||
|
||||
start(session)
|
||||
rescue Error => e
|
||||
Rails.logger.warn("[OverlayRelay] ensure_publishing session=#{session.id}: #{e.message}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mediamtx_rtmp_url
|
||||
ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935").chomp("/")
|
||||
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
|
||||
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["ready"] || info["available"])
|
||||
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 process_alive?(pid)
|
||||
Process.kill(0, pid.to_i)
|
||||
true
|
||||
rescue Errno::ESRCH, Errno::EPERM
|
||||
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
|
||||
103
backend/app/services/streams/youtube_relay.rb
Normal file
103
backend/app/services/streams/youtube_relay.rb
Normal file
@@ -0,0 +1,103 @@
|
||||
module Streams
|
||||
# Relay continuo verso YouTube: legge l'uscita MediaMTX del path (camera o slate alwaysAvailable)
|
||||
# senza interrompere FLV quando il telefono si mette in pausa o perde rete.
|
||||
class YoutubeRelay
|
||||
class Error < StandardError; end
|
||||
|
||||
REDIS_KEY = "youtube_relay:pid:%s"
|
||||
|
||||
class << self
|
||||
def start(session)
|
||||
return unless session.platform == "youtube"
|
||||
return if session.stream_key.blank?
|
||||
return if session.terminal?
|
||||
|
||||
stop(session) if pid_for(session.id).present?
|
||||
|
||||
log_path = log_file(session)
|
||||
FileUtils.mkdir_p(File.dirname(log_path))
|
||||
intake = mediamtx_intake_url(session)
|
||||
output = "rtmp://a.rtmp.youtube.com/live2/#{session.stream_key}"
|
||||
|
||||
pid = Process.spawn(
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||
"-rw_timeout", "15000000",
|
||||
"-i", intake,
|
||||
"-c", "copy",
|
||||
"-f", "flv",
|
||||
output,
|
||||
%i[out err] => log_path,
|
||||
pgroup: true
|
||||
)
|
||||
Process.detach(pid)
|
||||
store_pid(session.id, pid)
|
||||
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id}")
|
||||
pid
|
||||
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?
|
||||
|
||||
terminate_pid(pid)
|
||||
clear_pid(session.id)
|
||||
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id}")
|
||||
true
|
||||
end
|
||||
|
||||
def running?(session_id)
|
||||
pid = pid_for(session_id)
|
||||
pid.present? && process_alive?(pid)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mediamtx_intake_url(session)
|
||||
base = ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
|
||||
"#{base.chomp('/')}/#{session.mediamtx_overlay_path_name}"
|
||||
end
|
||||
|
||||
def log_file(session)
|
||||
Rails.root.join("log", "youtube_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 process_alive?(pid)
|
||||
Process.kill(0, pid.to_i)
|
||||
true
|
||||
rescue Errno::ESRCH, Errno::EPERM
|
||||
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
|
||||
@@ -25,21 +25,26 @@ module Webhooks
|
||||
def handle_connect(session)
|
||||
return if session.paused?
|
||||
|
||||
Streams::OverlayRelay.ensure_publishing!(session)
|
||||
|
||||
return if session.live? && session.stream_events.where(event_type: "connected").exists?
|
||||
|
||||
if session.reconnecting?
|
||||
session.reconnect! if session.may_reconnect?
|
||||
enable_recording(session)
|
||||
cancel_timeout(session)
|
||||
log(session, "reconnected")
|
||||
broadcast(session, "reconnected")
|
||||
elsif session.connecting? || session.idle?
|
||||
session.go_live! if session.may_go_live?
|
||||
enable_recording(session)
|
||||
log(session, "connected")
|
||||
broadcast(session, "connected")
|
||||
end
|
||||
end
|
||||
|
||||
def handle_disconnect(session)
|
||||
disable_recording(session)
|
||||
return if session.paused?
|
||||
return unless session.live? || session.connecting?
|
||||
|
||||
@@ -57,6 +62,20 @@ module Webhooks
|
||||
log(session, "connected", { ready: true })
|
||||
end
|
||||
|
||||
def enable_recording(session)
|
||||
return unless session.match.team.entitlements.recording_enabled_for_mediamtx?
|
||||
|
||||
Mediamtx::Client.new.set_path_recording(session, enabled: true)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[MediamtxHandler] enable recording: #{e.message}")
|
||||
end
|
||||
|
||||
def disable_recording(session)
|
||||
Mediamtx::Client.new.set_path_recording(session, enabled: false)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[MediamtxHandler] disable recording: #{e.message}")
|
||||
end
|
||||
|
||||
def schedule_timeout(session)
|
||||
job = DisconnectionTimeoutJob.perform_in(
|
||||
MatchLiveTv.reconnect_timeout_seconds.seconds,
|
||||
@@ -92,6 +111,7 @@ module Webhooks
|
||||
|
||||
def broadcast(session, event)
|
||||
SessionChannel.broadcast_message(session, { type: "stream_event", event: event })
|
||||
Streams::Overlay::Refresh.call(session)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user