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:
2026-06-03 23:55:17 +02:00
parent fd225fbadd
commit ab9cb02083
58 changed files with 2423 additions and 242 deletions

View 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

View 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

View 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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
end
end
end
end