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:
@@ -174,7 +174,7 @@ module Api
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# L'app invia fps ogni ~10s; senza poll status.json l'overlay YouTube non partiva.
|
# L'app invia fps ogni ~10s; poll status.json per allineare stato sessione e relay YouTube.
|
||||||
def sync_publisher_when_streaming!(fps)
|
def sync_publisher_when_streaming!(fps)
|
||||||
return if fps < 1
|
return if fps < 1
|
||||||
return if @session.terminal? || @session.paused?
|
return if @session.terminal? || @session.paused?
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
class OverlayRefreshJob < ApplicationJob
|
|
||||||
queue_as :default
|
|
||||||
|
|
||||||
INTERVAL = 2.seconds
|
|
||||||
REDIS_CHAIN_KEY = "overlay_refresh:chain:%s"
|
|
||||||
|
|
||||||
def self.schedule_chain(session_id)
|
|
||||||
redis.set(format(REDIS_CHAIN_KEY, session_id), "1", ex: 48.hours.to_i)
|
|
||||||
set(wait: INTERVAL).perform_later(session_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.cancel_chain(session_id)
|
|
||||||
redis.del(format(REDIS_CHAIN_KEY, session_id))
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.redis
|
|
||||||
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
|
||||||
end
|
|
||||||
|
|
||||||
def perform(session_id)
|
|
||||||
return unless self.class.redis.get(format(REDIS_CHAIN_KEY, session_id))
|
|
||||||
|
|
||||||
session = StreamSession.find_by(id: session_id)
|
|
||||||
return self.class.cancel_chain(session_id) unless session
|
|
||||||
return self.class.cancel_chain(session_id) if session.terminal?
|
|
||||||
return self.class.cancel_chain(session_id) unless Streams::OverlayRelay.running?(session_id)
|
|
||||||
|
|
||||||
Streams::Overlay::Refresh.call(session)
|
|
||||||
Mediamtx::PublisherSync.new(session).call if session.status.in?(%w[connecting reconnecting live paused])
|
|
||||||
self.class.set(wait: INTERVAL).perform_later(session_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# Avvia overlay ffmpeg solo in Sidekiq (un solo job per sessione alla volta).
|
|
||||||
class OverlayRelayEnsureJob < ApplicationJob
|
|
||||||
queue_as :default
|
|
||||||
|
|
||||||
LOCK_KEY = "overlay_ensure:lock:%s"
|
|
||||||
LOCK_TTL = 55
|
|
||||||
RETRY_WAIT = 5.seconds
|
|
||||||
|
|
||||||
class << self
|
|
||||||
def enqueue_unique(session_id, attempt: 1, force_restart: false, wait: 0.seconds)
|
|
||||||
key = format(LOCK_KEY, session_id)
|
|
||||||
r = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
|
||||||
return unless r.set(key, "1", nx: true, ex: LOCK_TTL)
|
|
||||||
|
|
||||||
set(wait: wait).perform_later(session_id, attempt, force_restart: force_restart)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def perform(session_id, attempt = 1, force_restart: false)
|
|
||||||
session = StreamSession.find_by(id: session_id)
|
|
||||||
unless session && !session.terminal?
|
|
||||||
clear_lock(session_id)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
return unless session.status.in?(%w[live connecting reconnecting paused])
|
|
||||||
|
|
||||||
begin
|
|
||||||
run_ensure(session, session_id, attempt, force_restart)
|
|
||||||
ensure
|
|
||||||
clear_lock(session_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def run_ensure(session, session_id, attempt, force_restart)
|
|
||||||
if Mediamtx::PublisherOnline.video_publishing?(session) && !session.paused?
|
|
||||||
session.go_live! if session.may_go_live?
|
|
||||||
session.reconnect! if session.reconnecting? && session.may_reconnect?
|
|
||||||
Mediamtx::Client.new.set_always_available(session, enabled: false)
|
|
||||||
end
|
|
||||||
|
|
||||||
if force_restart
|
|
||||||
Streams::OverlayRelay.stop(session)
|
|
||||||
Streams::OverlayRelay.terminate_all_session_processes!(session.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
Streams::OverlayRelay.ensure_publishing!(session)
|
|
||||||
|
|
||||||
if Streams::OverlayRelay.healthy?(session)
|
|
||||||
Youtube::LivePipeline.schedule_activate!(session, force: true, wait: 5.seconds)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
return if Streams::OverlayRelay.running?(session.id)
|
|
||||||
|
|
||||||
Rails.logger.info("[OverlayRelayEnsureJob] overlay not running, retry session=#{session_id} attempt=#{attempt}")
|
|
||||||
reschedule(session_id, attempt, false) if attempt < 30
|
|
||||||
rescue Mediamtx::Client::Error => e
|
|
||||||
Rails.logger.warn("[OverlayRelayEnsureJob] session=#{session_id}: #{e.message}")
|
|
||||||
end
|
|
||||||
|
|
||||||
def reschedule(session_id, attempt, force_restart)
|
|
||||||
clear_lock(session_id)
|
|
||||||
self.class.enqueue_unique(
|
|
||||||
session_id,
|
|
||||||
attempt: attempt + 1,
|
|
||||||
force_restart: force_restart,
|
|
||||||
wait: RETRY_WAIT
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def clear_lock(session_id)
|
|
||||||
redis.del(format(LOCK_KEY, session_id))
|
|
||||||
end
|
|
||||||
|
|
||||||
def redis
|
|
||||||
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# Aggiorna tabellone overlay in background (evita blocchi HTTP al +1 punto).
|
|
||||||
class OverlayScoreRefreshJob < ApplicationJob
|
|
||||||
queue_as :default
|
|
||||||
|
|
||||||
def perform(session_id)
|
|
||||||
session = StreamSession.find_by(id: session_id)
|
|
||||||
return unless session
|
|
||||||
return if session.terminal?
|
|
||||||
|
|
||||||
Streams::Overlay::Refresh.call(session)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -12,21 +12,6 @@ module Mediamtx
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_overlay_path(session)
|
|
||||||
path = session.mediamtx_overlay_path_name
|
|
||||||
body = {
|
|
||||||
source: "publisher",
|
|
||||||
overridePublisher: true,
|
|
||||||
record: false
|
|
||||||
}
|
|
||||||
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)
|
def create_path(session)
|
||||||
path = session.mediamtx_path_name
|
path = session.mediamtx_path_name
|
||||||
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -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
|
|
||||||
@@ -116,7 +116,7 @@ module Streams
|
|||||||
raise Error, "ffmpeg non disponibile: #{e.message}"
|
raise Error, "ffmpeg non disponibile: #{e.message}"
|
||||||
end
|
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)
|
def youtube_ffmpeg_args(intake_source, output)
|
||||||
mode, url = intake_source
|
mode, url = intake_source
|
||||||
if mode == :rtmp
|
if mode == :rtmp
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
<div class="live-grid">
|
<div class="live-grid">
|
||||||
<% @sessions.each do |session| %>
|
<% @sessions.each do |session| %>
|
||||||
<% match = session.match %>
|
<% match = session.match %>
|
||||||
<% on_air = @online_paths.include?(session.mediamtx_overlay_path_name) || @online_paths.include?(session.mediamtx_path_name) %>
|
<% on_air = @online_paths.include?(session.mediamtx_path_name) %>
|
||||||
<article class="live-card">
|
<article class="live-card">
|
||||||
<%= live_match_card_heading(match) %>
|
<%= live_match_card_heading(match) %>
|
||||||
<p class="meta">
|
<p class="meta">
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
<div class="live-player-wrap">
|
<div class="live-player-wrap">
|
||||||
<video id="player" controls playsinline muted autoplay poster="/images/copertina-canale.png"></video>
|
<video id="player" controls playsinline muted autoplay poster="/images/copertina-canale.png"></video>
|
||||||
<%# Tabellone, badge stato e brand sono bruciati nel video (Streams::OverlayRelay). %>
|
<%# Tabellone e watermark sono bruciati nel video dall'app mobile (overlay client-side). %>
|
||||||
<button type="button" id="play-hint" class="live-play-hint" hidden>
|
<button type="button" id="play-hint" class="live-play-hint" hidden>
|
||||||
▶ Avvia la diretta
|
▶ Avvia la diretta
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/usr/bin/env ruby
|
|
||||||
# Invia overlay.png aggiornata a ffmpeg via image2pipe (FIFO).
|
|
||||||
# ffmpeg con -loop 1 legge il file una sola volta: questo processo rilegge il PNG
|
|
||||||
# quando cambia mtime (punteggio, badge stato, …).
|
|
||||||
require "fileutils"
|
|
||||||
|
|
||||||
pipe_path = ARGV.fetch(0)
|
|
||||||
png_path = ARGV.fetch(1)
|
|
||||||
poll_sec = (ARGV[2] || ENV.fetch("OVERLAY_PIPE_POLL_SEC", "0.4")).to_f
|
|
||||||
|
|
||||||
abort "pipe mancante: #{pipe_path}" unless File.exist?(pipe_path)
|
|
||||||
abort "png mancante: #{png_path}" unless File.exist?(png_path)
|
|
||||||
|
|
||||||
stop = false
|
|
||||||
trap("TERM") { stop = true }
|
|
||||||
trap("INT") { stop = true }
|
|
||||||
|
|
||||||
# image2pipe richiede frame continui: inviamo la PNG a ritmo fisso ( anche se invariata ).
|
|
||||||
last_bytes = nil
|
|
||||||
File.open(pipe_path, "wb") do |pipe|
|
|
||||||
loop do
|
|
||||||
break if stop
|
|
||||||
|
|
||||||
if File.exist?(png_path)
|
|
||||||
bytes = File.binread(png_path)
|
|
||||||
# Evita frame corrotti durante rsvg-convert (file parziale prima del rename atomico).
|
|
||||||
next if bytes.bytesize < 512
|
|
||||||
|
|
||||||
if bytes != last_bytes
|
|
||||||
pipe.write(bytes)
|
|
||||||
pipe.flush
|
|
||||||
last_bytes = bytes
|
|
||||||
elsif last_bytes
|
|
||||||
pipe.write(last_bytes)
|
|
||||||
pipe.flush
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
sleep poll_sec
|
|
||||||
end
|
|
||||||
rescue Errno::EPIPE
|
|
||||||
# ffmpeg chiuso
|
|
||||||
end
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
namespace :stream_overlay do
|
|
||||||
desc "Avvia path _air e OverlayRelay per sessioni attive (dopo deploy overlay server)"
|
|
||||||
task start_active: :environment do
|
|
||||||
sessions = StreamSession.where(status: %w[live connecting reconnecting paused])
|
|
||||||
mtx = Mediamtx::Client.new
|
|
||||||
sessions.find_each do |session|
|
|
||||||
begin
|
|
||||||
mtx.create_overlay_path(session)
|
|
||||||
rescue Mediamtx::Client::Error => e
|
|
||||||
Rails.logger.info("[stream_overlay:start_active] path #{session.id}: #{e.message}")
|
|
||||||
end
|
|
||||||
Streams::OverlayRelay.start(session)
|
|
||||||
puts "Overlay avviato per sessione #{session.id}"
|
|
||||||
rescue StandardError => e
|
|
||||||
warn "Sessione #{session.id}: #{e.message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -115,7 +115,6 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- recordings:/recordings
|
- recordings:/recordings
|
||||||
- stream_overlays:/app/tmp/stream_overlays
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
@@ -163,7 +162,6 @@ services:
|
|||||||
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
|
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
|
||||||
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
|
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
|
||||||
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
|
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
|
||||||
OVERLAY_RELAY_PROCESS_CHECK: "true"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
rails:
|
rails:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -171,7 +169,6 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- recordings:/recordings
|
- recordings:/recordings
|
||||||
- stream_overlays:/app/tmp/stream_overlays
|
|
||||||
|
|
||||||
garage:
|
garage:
|
||||||
image: dxflrs/garage:v1.0.1
|
image: dxflrs/garage:v1.0.1
|
||||||
@@ -187,6 +184,5 @@ volumes:
|
|||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
recordings:
|
recordings:
|
||||||
stream_overlays:
|
|
||||||
garage_meta:
|
garage_meta:
|
||||||
garage_data:
|
garage_data:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.matchlivetv.match_live_tv.streaming
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -28,6 +29,10 @@ class LiveBroadcastCoordinator(context: Context) {
|
|||||||
engine.resumeAfterConfigurationChange()
|
engine.resumeAfterConfigurationChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateOverlay(state: OverlayState) {
|
||||||
|
engine.updateOverlay(state)
|
||||||
|
}
|
||||||
|
|
||||||
fun startService() {
|
fun startService() {
|
||||||
val intent = LiveBroadcastService.startIntent(appContext)
|
val intent = LiveBroadcastService.startIntent(appContext)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import android.view.WindowManager
|
|||||||
import com.pedro.common.ConnectChecker
|
import com.pedro.common.ConnectChecker
|
||||||
import com.pedro.library.generic.GenericStream
|
import com.pedro.library.generic.GenericStream
|
||||||
import com.pedro.library.util.SensorRotationManager
|
import com.pedro.library.util.SensorRotationManager
|
||||||
|
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayRenderer
|
||||||
|
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ class LiveBroadcastEngine(
|
|||||||
private var currentFps: Int = 0
|
private var currentFps: Int = 0
|
||||||
private val pausingIntentionally = AtomicBoolean(false)
|
private val pausingIntentionally = AtomicBoolean(false)
|
||||||
private val reconnectAttempts = AtomicInteger(0)
|
private val reconnectAttempts = AtomicInteger(0)
|
||||||
|
private val overlayRenderer = OverlayRenderer(appContext)
|
||||||
|
|
||||||
private val rebindRunnable = Runnable {
|
private val rebindRunnable = Runnable {
|
||||||
reattachPreviewIfNeeded()
|
reattachPreviewIfNeeded()
|
||||||
@@ -56,6 +59,13 @@ class LiveBroadcastEngine(
|
|||||||
emitMetrics()
|
emitMetrics()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Aggiorna tabellone, watermark e futuri elementi grafici sul frame video. */
|
||||||
|
fun updateOverlay(state: OverlayState) {
|
||||||
|
mainHandler.post {
|
||||||
|
overlayRenderer.update(state, isPortrait = lastAppliedPortrait == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun bindPreview(view: LivePreviewView) {
|
fun bindPreview(view: LivePreviewView) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
previewView = view
|
previewView = view
|
||||||
@@ -208,6 +218,7 @@ class LiveBroadcastEngine(
|
|||||||
if (stream.isStreaming) stream.stopStream()
|
if (stream.isStreaming) stream.stopStream()
|
||||||
if (stream.isOnPreview) stream.stopPreview()
|
if (stream.isOnPreview) stream.stopPreview()
|
||||||
}
|
}
|
||||||
|
overlayRenderer.detach()
|
||||||
genericStream = null
|
genericStream = null
|
||||||
surfaceSuspended = false
|
surfaceSuspended = false
|
||||||
rebindPending = false
|
rebindPending = false
|
||||||
@@ -303,10 +314,26 @@ class LiveBroadcastEngine(
|
|||||||
|
|
||||||
resetOrientationTracking()
|
resetOrientationTracking()
|
||||||
applyInitialOrientation()
|
applyInitialOrientation()
|
||||||
|
attachOverlayFilter(cfg)
|
||||||
startSensor()
|
startSensor()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun attachOverlayFilter(cfg: BroadcastConfig) {
|
||||||
|
val stream = genericStream ?: return
|
||||||
|
overlayRenderer.detach()
|
||||||
|
overlayRenderer.attach(
|
||||||
|
gl = stream.getGlInterface(),
|
||||||
|
width = cfg.width,
|
||||||
|
height = cfg.height,
|
||||||
|
isPortrait = lastAppliedPortrait == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshOverlayLayout(isPortrait: Boolean) {
|
||||||
|
overlayRenderer.refreshOrientation(isPortrait)
|
||||||
|
}
|
||||||
|
|
||||||
/** Stessa convenzione di SensorRotationManager (0° = portrait naturale). */
|
/** Stessa convenzione di SensorRotationManager (0° = portrait naturale). */
|
||||||
private fun orientationFromDisplayRotation(): Pair<Int, Boolean> {
|
private fun orientationFromDisplayRotation(): Pair<Int, Boolean> {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@@ -402,6 +429,7 @@ class LiveBroadcastEngine(
|
|||||||
lastAppliedRotation = rotation
|
lastAppliedRotation = rotation
|
||||||
lastAppliedPortrait = isPortrait
|
lastAppliedPortrait = isPortrait
|
||||||
pendingIsPortrait = isPortrait
|
pendingIsPortrait = isPortrait
|
||||||
|
config?.let { refreshOverlayLayout(isPortrait) }
|
||||||
Log.i(TAG, "$logLabel orientation rotation=$rotation portrait=$isPortrait phase=$phase")
|
Log.i(TAG, "$logLabel orientation rotation=$rotation portrait=$isPortrait phase=$phase")
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
Log.w(TAG, "$logLabel orientation failed: ${it.message}")
|
Log.w(TAG, "$logLabel orientation failed: ${it.message}")
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.streaming.overlay
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compone gli [OverlayElement] su un bitmap trasparente alle dimensioni del frame encoder.
|
||||||
|
*/
|
||||||
|
class OverlayCanvasRenderer(context: Context) {
|
||||||
|
|
||||||
|
private val elements: List<OverlayElement> = listOf(
|
||||||
|
WatermarkElement(context),
|
||||||
|
ScoreboardElement(),
|
||||||
|
TimerElement(),
|
||||||
|
SponsorElement(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private var bitmap: Bitmap? = null
|
||||||
|
private var canvasWidth = 0
|
||||||
|
private var canvasHeight = 0
|
||||||
|
|
||||||
|
fun render(state: OverlayState, isPortraitContent: Boolean): Bitmap {
|
||||||
|
val width = canvasWidth
|
||||||
|
val height = canvasHeight
|
||||||
|
require(width > 0 && height > 0) { "Canvas size not configured" }
|
||||||
|
|
||||||
|
val bmp = obtainBitmap(width, height)
|
||||||
|
bmp.eraseColor(Color.TRANSPARENT)
|
||||||
|
val canvas = Canvas(bmp)
|
||||||
|
|
||||||
|
val margin = max(12, (width * 0.012f).roundToInt())
|
||||||
|
val layout = OverlayLayout(
|
||||||
|
canvasWidth = width,
|
||||||
|
canvasHeight = height,
|
||||||
|
marginPx = margin,
|
||||||
|
isPortraitContent = isPortraitContent,
|
||||||
|
)
|
||||||
|
elements.forEach { it.draw(canvas, state, layout) }
|
||||||
|
return bmp
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCanvasSize(width: Int, height: Int) {
|
||||||
|
if (width == canvasWidth && height == canvasHeight) return
|
||||||
|
canvasWidth = width
|
||||||
|
canvasHeight = height
|
||||||
|
bitmap?.recycle()
|
||||||
|
bitmap = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
bitmap?.recycle()
|
||||||
|
bitmap = null
|
||||||
|
canvasWidth = 0
|
||||||
|
canvasHeight = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun obtainBitmap(width: Int, height: Int): Bitmap {
|
||||||
|
val existing = bitmap
|
||||||
|
if (existing != null && !existing.isRecycled &&
|
||||||
|
existing.width == width && existing.height == height
|
||||||
|
) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
existing?.recycle()
|
||||||
|
return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmap = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.streaming.overlay
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
|
||||||
|
/** Layout normalizzato sul canvas encoder (720p, 1080p, …). */
|
||||||
|
data class OverlayLayout(
|
||||||
|
val canvasWidth: Int,
|
||||||
|
val canvasHeight: Int,
|
||||||
|
val marginPx: Int,
|
||||||
|
val isPortraitContent: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Componente grafico modulare da comporre sull'overlay. */
|
||||||
|
interface OverlayElement {
|
||||||
|
fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.streaming.overlay
|
||||||
|
|
||||||
|
import com.matchlivetv.match_live_tv.domain.ScoreState
|
||||||
|
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
|
||||||
|
|
||||||
|
fun ScoreState.toScoreboardState(homeTeamName: String, awayTeamName: String): ScoreboardState {
|
||||||
|
val columns = buildList {
|
||||||
|
setPartials.forEach { partial ->
|
||||||
|
add(
|
||||||
|
ScoreboardSetColumn(
|
||||||
|
setNumber = partial.set,
|
||||||
|
homePoints = partial.home,
|
||||||
|
awayPoints = partial.away,
|
||||||
|
isCurrent = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(
|
||||||
|
ScoreboardSetColumn(
|
||||||
|
setNumber = currentSet,
|
||||||
|
homePoints = homePoints,
|
||||||
|
awayPoints = awayPoints,
|
||||||
|
isCurrent = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ScoreboardState(
|
||||||
|
homeTeamName = homeTeamName,
|
||||||
|
awayTeamName = awayTeamName,
|
||||||
|
columns = columns,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BroadcastPhase.toOverlayStatus(): BroadcastOverlayStatus = when (this) {
|
||||||
|
BroadcastPhase.LIVE -> BroadcastOverlayStatus.LIVE
|
||||||
|
BroadcastPhase.PAUSED -> BroadcastOverlayStatus.PAUSED
|
||||||
|
BroadcastPhase.CONNECTING, BroadcastPhase.RECONNECTING -> BroadcastOverlayStatus.CONNECTING
|
||||||
|
BroadcastPhase.PREVIEW, BroadcastPhase.IDLE -> BroadcastOverlayStatus.PREVIEW
|
||||||
|
BroadcastPhase.ERROR -> BroadcastOverlayStatus.CONNECTING
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.streaming.overlay
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.pedro.encoder.input.gl.render.filters.`object`.ImageObjectFilterRender
|
||||||
|
import com.pedro.encoder.utils.gl.TranslateTo
|
||||||
|
import com.pedro.library.view.GlInterface
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applica l'overlay grafico nella pipeline OpenGL di RootEncoder (preview + RTMP + recording),
|
||||||
|
* senza ricodifica aggiuntiva: un solo [ImageObjectFilterRender] composita il bitmap sul frame camera.
|
||||||
|
*/
|
||||||
|
class OverlayRenderer(context: Context) {
|
||||||
|
|
||||||
|
private val canvasRenderer = OverlayCanvasRenderer(context.applicationContext)
|
||||||
|
private var filter: ImageObjectFilterRender? = null
|
||||||
|
private var glInterface: GlInterface? = null
|
||||||
|
private var streamWidth = 0
|
||||||
|
private var streamHeight = 0
|
||||||
|
private var isPortraitContent = false
|
||||||
|
private var attached = false
|
||||||
|
private var lastFingerprint = Int.MIN_VALUE
|
||||||
|
private var pendingState: OverlayState? = null
|
||||||
|
|
||||||
|
fun attach(gl: GlInterface, width: Int, height: Int, isPortrait: Boolean = false) {
|
||||||
|
if (attached && gl === glInterface && width == streamWidth && height == streamHeight) {
|
||||||
|
isPortraitContent = isPortrait
|
||||||
|
pendingState?.let { pushState(it) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detach()
|
||||||
|
glInterface = gl
|
||||||
|
streamWidth = width
|
||||||
|
streamHeight = height
|
||||||
|
isPortraitContent = isPortrait
|
||||||
|
canvasRenderer.setCanvasSize(width, height)
|
||||||
|
|
||||||
|
val overlayFilter = ImageObjectFilterRender()
|
||||||
|
gl.addFilter(overlayFilter)
|
||||||
|
overlayFilter.setPosition(TranslateTo.TOP_LEFT)
|
||||||
|
overlayFilter.setDefaultScale(width, height)
|
||||||
|
filter = overlayFilter
|
||||||
|
attached = true
|
||||||
|
lastFingerprint = Int.MIN_VALUE
|
||||||
|
|
||||||
|
pendingState?.let { pushState(it) }
|
||||||
|
Log.i(TAG, "overlay attached ${width}x$height portrait=$isPortrait")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(state: OverlayState, isPortrait: Boolean = isPortraitContent) {
|
||||||
|
isPortraitContent = isPortrait
|
||||||
|
pendingState = state
|
||||||
|
if (!attached) return
|
||||||
|
pushState(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ridisegna l'overlay dopo rotazione dispositivo (senza ri-attach GL). */
|
||||||
|
fun refreshOrientation(isPortrait: Boolean) {
|
||||||
|
if (isPortraitContent == isPortrait && attached) return
|
||||||
|
isPortraitContent = isPortrait
|
||||||
|
lastFingerprint = Int.MIN_VALUE
|
||||||
|
pendingState?.let { pushState(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detach() {
|
||||||
|
if (attached) {
|
||||||
|
runCatching { glInterface?.clearFilters() }
|
||||||
|
.onFailure { Log.w(TAG, "clearFilters: ${it.message}") }
|
||||||
|
}
|
||||||
|
filter = null
|
||||||
|
glInterface = null
|
||||||
|
attached = false
|
||||||
|
lastFingerprint = Int.MIN_VALUE
|
||||||
|
canvasRenderer.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pushState(state: OverlayState) {
|
||||||
|
val fp = state.fingerprint()
|
||||||
|
if (fp == lastFingerprint) return
|
||||||
|
lastFingerprint = fp
|
||||||
|
|
||||||
|
val bmp = canvasRenderer.render(state, isPortraitContent)
|
||||||
|
filter?.let { overlayFilter ->
|
||||||
|
overlayFilter.setDefaultScale(streamWidth, streamHeight)
|
||||||
|
overlayFilter.setPosition(TranslateTo.TOP_LEFT)
|
||||||
|
overlayFilter.setImage(bmp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "OverlayRenderer"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.streaming.overlay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stato grafico dell'overlay video. Indipendente dalla logica sportiva:
|
||||||
|
* il tabellone è descritto da [ScoreboardState], non da regole di gioco.
|
||||||
|
*/
|
||||||
|
data class ScoreboardSetColumn(
|
||||||
|
val setNumber: Int,
|
||||||
|
val homePoints: Int,
|
||||||
|
val awayPoints: Int,
|
||||||
|
val isCurrent: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScoreboardState(
|
||||||
|
val homeTeamName: String,
|
||||||
|
val awayTeamName: String,
|
||||||
|
val columns: List<ScoreboardSetColumn>,
|
||||||
|
val homeAccentColor: Int = 0xFFFF2D2D.toInt(),
|
||||||
|
val awayAccentColor: Int = 0xFF1E3A8A.toInt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class BroadcastOverlayStatus {
|
||||||
|
PREVIEW,
|
||||||
|
CONNECTING,
|
||||||
|
LIVE,
|
||||||
|
PAUSED,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class OverlayState(
|
||||||
|
val scoreboard: ScoreboardState? = null,
|
||||||
|
val watermarkVisible: Boolean = true,
|
||||||
|
val broadcastStatus: BroadcastOverlayStatus = BroadcastOverlayStatus.LIVE,
|
||||||
|
val timerText: String? = null,
|
||||||
|
val sponsorText: String? = null,
|
||||||
|
) {
|
||||||
|
fun fingerprint(): Int {
|
||||||
|
var result = watermarkVisible.hashCode()
|
||||||
|
result = 31 * result + broadcastStatus.hashCode()
|
||||||
|
result = 31 * result + (timerText?.hashCode() ?: 0)
|
||||||
|
result = 31 * result + (sponsorText?.hashCode() ?: 0)
|
||||||
|
result = 31 * result + (scoreboard?.fingerprint() ?: 0)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ScoreboardState.fingerprint(): Int {
|
||||||
|
var result = homeTeamName.hashCode()
|
||||||
|
result = 31 * result + awayTeamName.hashCode()
|
||||||
|
result = 31 * result + homeAccentColor
|
||||||
|
result = 31 * result + awayAccentColor
|
||||||
|
columns.forEach { col ->
|
||||||
|
result = 31 * result + col.setNumber
|
||||||
|
result = 31 * result + col.homePoints
|
||||||
|
result = 31 * result + col.awayPoints
|
||||||
|
result = 31 * result + col.isCurrent.hashCode()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.streaming.overlay
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.text.TextPaint
|
||||||
|
import android.text.TextUtils
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tabellone stile broadcast (SportCam): alto-sinistra, una riga per squadra,
|
||||||
|
* colonne allineate per set conclusi + set in corso.
|
||||||
|
*/
|
||||||
|
class ScoreboardElement : OverlayElement {
|
||||||
|
|
||||||
|
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.argb(210, 12, 12, 12)
|
||||||
|
}
|
||||||
|
private val headerPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.parseColor("#888888")
|
||||||
|
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
|
||||||
|
}
|
||||||
|
private val teamPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.WHITE
|
||||||
|
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
|
||||||
|
}
|
||||||
|
private val scorePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.WHITE
|
||||||
|
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
}
|
||||||
|
private val accentBarPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val winPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.parseColor("#FF2D2D")
|
||||||
|
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
}
|
||||||
|
private val footerPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.parseColor("#CCCCCC")
|
||||||
|
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)
|
||||||
|
}
|
||||||
|
private val rect = RectF()
|
||||||
|
|
||||||
|
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
|
||||||
|
val board = state.scoreboard ?: return
|
||||||
|
if (board.columns.isEmpty()) return
|
||||||
|
|
||||||
|
val scale = layout.canvasHeight / 720f * SIZE_BOOST
|
||||||
|
val pad = (8f * scale).roundToInt().coerceAtLeast(6)
|
||||||
|
val colorBarW = (5f * scale).roundToInt().coerceAtLeast(4)
|
||||||
|
val teamW = (118f * scale).roundToInt()
|
||||||
|
val colW = (28f * scale).roundToInt().coerceAtLeast(22)
|
||||||
|
val headH = (16f * scale).roundToInt().coerceAtLeast(12)
|
||||||
|
val rowH = (20f * scale).roundToInt().coerceAtLeast(16)
|
||||||
|
val footerH = (18f * scale).roundToInt().coerceAtLeast(14)
|
||||||
|
val n = board.columns.size
|
||||||
|
|
||||||
|
val boxW = pad * 2 + colorBarW + teamW + n * colW
|
||||||
|
val boxH = pad * 2 + headH + rowH * 2 + footerH
|
||||||
|
val left = layout.marginPx
|
||||||
|
val top = layout.marginPx
|
||||||
|
|
||||||
|
rect.set(left.toFloat(), top.toFloat(), (left + boxW).toFloat(), (top + boxH).toFloat())
|
||||||
|
canvas.drawRoundRect(rect, 6f * scale, 6f * scale, backgroundPaint)
|
||||||
|
|
||||||
|
val headerSize = (10f * scale).coerceAtLeast(8f)
|
||||||
|
val teamSize = (11f * scale).coerceAtLeast(9f)
|
||||||
|
val scoreSize = (13f * scale).coerceAtLeast(10f)
|
||||||
|
val footerSize = (10f * scale).coerceAtLeast(8f)
|
||||||
|
headerPaint.textSize = headerSize
|
||||||
|
teamPaint.textSize = teamSize
|
||||||
|
scorePaint.textSize = scoreSize
|
||||||
|
winPaint.textSize = scoreSize
|
||||||
|
footerPaint.textSize = footerSize
|
||||||
|
|
||||||
|
val scoresLeft = left + pad + colorBarW + teamW
|
||||||
|
val headerBaseline = top + pad + headH - (2f * scale)
|
||||||
|
|
||||||
|
board.columns.forEachIndexed { index, column ->
|
||||||
|
val cx = scoresLeft + index * colW + colW / 2f
|
||||||
|
canvas.drawText(column.setNumber.toString(), cx, headerBaseline, headerPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
val homeRowTop = top + pad + headH
|
||||||
|
val awayRowTop = homeRowTop + rowH
|
||||||
|
drawTeamRow(
|
||||||
|
canvas = canvas,
|
||||||
|
board = board,
|
||||||
|
isHome = true,
|
||||||
|
rowTop = homeRowTop,
|
||||||
|
left = left,
|
||||||
|
pad = pad,
|
||||||
|
colorBarW = colorBarW,
|
||||||
|
teamW = teamW,
|
||||||
|
colW = colW,
|
||||||
|
rowH = rowH,
|
||||||
|
scoresLeft = scoresLeft,
|
||||||
|
scale = scale,
|
||||||
|
)
|
||||||
|
drawTeamRow(
|
||||||
|
canvas = canvas,
|
||||||
|
board = board,
|
||||||
|
isHome = false,
|
||||||
|
rowTop = awayRowTop,
|
||||||
|
left = left,
|
||||||
|
pad = pad,
|
||||||
|
colorBarW = colorBarW,
|
||||||
|
teamW = teamW,
|
||||||
|
colW = colW,
|
||||||
|
rowH = rowH,
|
||||||
|
scoresLeft = scoresLeft,
|
||||||
|
scale = scale,
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentSet = board.columns.lastOrNull { it.isCurrent }?.setNumber ?: board.columns.size
|
||||||
|
val footerText = ordinalSetLabel(currentSet)
|
||||||
|
val footerBaseline = rect.bottom - pad - (2f * scale)
|
||||||
|
canvas.drawText(footerText, rect.left + pad + colorBarW + 4f * scale, footerBaseline, footerPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawTeamRow(
|
||||||
|
canvas: Canvas,
|
||||||
|
board: ScoreboardState,
|
||||||
|
isHome: Boolean,
|
||||||
|
rowTop: Int,
|
||||||
|
left: Int,
|
||||||
|
pad: Int,
|
||||||
|
colorBarW: Int,
|
||||||
|
teamW: Int,
|
||||||
|
colW: Int,
|
||||||
|
rowH: Int,
|
||||||
|
scoresLeft: Int,
|
||||||
|
scale: Float,
|
||||||
|
) {
|
||||||
|
accentBarPaint.color = if (isHome) board.homeAccentColor else board.awayAccentColor
|
||||||
|
val barLeft = (left + pad).toFloat()
|
||||||
|
val barTop = rowTop + (2f * scale)
|
||||||
|
val barBottom = rowTop + rowH - (2f * scale)
|
||||||
|
canvas.drawRect(barLeft, barTop, barLeft + colorBarW, barBottom, accentBarPaint)
|
||||||
|
|
||||||
|
val rawName = if (isHome) board.homeTeamName else board.awayTeamName
|
||||||
|
val name = abbreviate(rawName).uppercase()
|
||||||
|
val nameMaxW = teamW - (8f * scale)
|
||||||
|
val displayName = ellipsize(name, teamPaint, nameMaxW)
|
||||||
|
val nameBaseline = rowTop + rowH * 0.72f
|
||||||
|
canvas.drawText(displayName, barLeft + colorBarW + 6f * scale, nameBaseline, teamPaint)
|
||||||
|
|
||||||
|
board.columns.forEachIndexed { index, column ->
|
||||||
|
val cx = scoresLeft + index * colW + colW / 2f
|
||||||
|
val score = if (isHome) column.homePoints else column.awayPoints
|
||||||
|
val paint = scorePaintFor(column, isHome)
|
||||||
|
canvas.drawText(score.toString(), cx, nameBaseline, paint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scorePaintFor(column: ScoreboardSetColumn, isHome: Boolean): TextPaint {
|
||||||
|
if (column.isCurrent) return scorePaint
|
||||||
|
val homeWon = column.homePoints > column.awayPoints
|
||||||
|
val awayWon = column.awayPoints > column.homePoints
|
||||||
|
return when {
|
||||||
|
isHome && homeWon -> winPaint
|
||||||
|
!isHome && awayWon -> winPaint
|
||||||
|
else -> scorePaint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abbreviate(name: String): String {
|
||||||
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.length <= 12) return trimmed
|
||||||
|
return trimmed.take(11) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ellipsize(text: String, paint: TextPaint, maxWidth: Float): String {
|
||||||
|
val safeMax = max(32f, maxWidth)
|
||||||
|
return TextUtils.ellipsize(text, paint, safeMax, TextUtils.TruncateAt.END)?.toString() ?: text
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SIZE_BOOST = 1.1f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ordinalSetLabel(setNumber: Int): String = when (setNumber) {
|
||||||
|
1 -> "1° set"
|
||||||
|
2 -> "2° set"
|
||||||
|
3 -> "3° set"
|
||||||
|
4 -> "4° set"
|
||||||
|
5 -> "5° set"
|
||||||
|
else -> "Set $setNumber"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.streaming.overlay
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Typeface
|
||||||
|
|
||||||
|
/** Placeholder lower-third sponsor (MVP: disegna solo se [OverlayState.sponsorText] è valorizzato). */
|
||||||
|
class SponsorElement : OverlayElement {
|
||||||
|
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.argb(220, 245, 197, 24)
|
||||||
|
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
|
||||||
|
val text = state.sponsorText?.takeIf { it.isNotBlank() } ?: return
|
||||||
|
paint.textSize = layout.canvasHeight * 0.045f
|
||||||
|
val width = paint.measureText(text)
|
||||||
|
val x = (layout.canvasWidth - width) / 2f
|
||||||
|
val y = layout.canvasHeight - layout.marginPx * 2.2f
|
||||||
|
canvas.drawText(text, x, y, paint)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.streaming.overlay
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Typeface
|
||||||
|
|
||||||
|
/** Placeholder per cronometro / timeout (MVP: disegna solo se [OverlayState.timerText] è valorizzato). */
|
||||||
|
class TimerElement : OverlayElement {
|
||||||
|
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.WHITE
|
||||||
|
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
|
||||||
|
val text = state.timerText?.takeIf { it.isNotBlank() } ?: return
|
||||||
|
paint.textSize = layout.canvasHeight * 0.05f
|
||||||
|
val x = layout.marginPx.toFloat()
|
||||||
|
val y = layout.canvasHeight - layout.marginPx.toFloat()
|
||||||
|
canvas.drawText(text, x, y, paint)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.streaming.overlay
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import com.matchlivetv.match_live_tv.R
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class WatermarkElement(context: Context) : OverlayElement {
|
||||||
|
|
||||||
|
private val logo: Bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.logo_white)
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
|
||||||
|
alpha = 255
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
|
||||||
|
if (!state.watermarkVisible) return
|
||||||
|
|
||||||
|
val targetWidth = (layout.canvasWidth * LOGO_WIDTH_RATIO).roundToInt().coerceIn(28, 72)
|
||||||
|
val scale = targetWidth.toFloat() / logo.width
|
||||||
|
val targetHeight = (logo.height * scale).roundToInt()
|
||||||
|
|
||||||
|
val left = layout.canvasWidth - layout.marginPx - targetWidth
|
||||||
|
val top = layout.canvasHeight - layout.marginPx - targetHeight
|
||||||
|
|
||||||
|
val dest = android.graphics.Rect(left, top, left + targetWidth, top + targetHeight)
|
||||||
|
canvas.drawBitmap(logo, null, dest, paint)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val LOGO_WIDTH_RATIO = 0.055f
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,9 @@ import com.matchlivetv.match_live_tv.domain.StreamSession
|
|||||||
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
|
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
|
||||||
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
|
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
|
||||||
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
|
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
|
||||||
|
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState
|
||||||
|
import com.matchlivetv.match_live_tv.streaming.overlay.toOverlayStatus
|
||||||
|
import com.matchlivetv.match_live_tv.streaming.overlay.toScoreboardState
|
||||||
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||||
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
||||||
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
|
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
|
||||||
@@ -187,6 +190,17 @@ fun BroadcastScreen(
|
|||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(score, match, metrics.phase) {
|
||||||
|
val currentMatch = match ?: return@LaunchedEffect
|
||||||
|
container.broadcastCoordinator.updateOverlay(
|
||||||
|
OverlayState(
|
||||||
|
scoreboard = score.toScoreboardState(currentMatch.teamName, currentMatch.opponentName),
|
||||||
|
watermarkVisible = true,
|
||||||
|
broadcastStatus = metrics.phase.toOverlayStatus(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(sessionId) {
|
LaunchedEffect(sessionId) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(10_000)
|
delay(10_000)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 8.1 KiB |
Reference in New Issue
Block a user