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:
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
|
||||
Reference in New Issue
Block a user