From f3ff657fc2d5adfd3084be4527281b94e7e699a6 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Sat, 6 Jun 2026 18:12:35 +0200 Subject: [PATCH] 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 --- .../api/v1/stream_sessions_controller.rb | 2 +- backend/app/jobs/overlay_refresh_job.rb | 32 -- backend/app/jobs/overlay_relay_ensure_job.rb | 80 --- backend/app/jobs/overlay_score_refresh_job.rb | 12 - backend/app/services/mediamtx/client.rb | 15 - backend/app/services/streams/overlay.rb | 46 -- .../services/streams/overlay/badge_label.rb | 49 -- .../app/services/streams/overlay/refresh.rb | 50 -- .../services/streams/overlay/svg_builder.rb | 162 ------ backend/app/services/streams/overlay_relay.rb | 460 ------------------ backend/app/services/streams/youtube_relay.rb | 2 +- backend/app/views/public/live/index.html.erb | 2 +- backend/app/views/public/live/show.html.erb | 2 +- backend/bin/overlay_png_feeder.rb | 43 -- backend/lib/tasks/stream_overlay.rake | 18 - infra/docker-compose.prod.yml | 4 - .../streaming/LiveBroadcastCoordinator.kt | 5 + .../streaming/LiveBroadcastEngine.kt | 28 ++ .../overlay/OverlayCanvasRenderer.kt | 71 +++ .../streaming/overlay/OverlayElement.kt | 16 + .../streaming/overlay/OverlayMappings.kt | 40 ++ .../streaming/overlay/OverlayRenderer.kt | 94 ++++ .../streaming/overlay/OverlayState.kt | 58 +++ .../streaming/overlay/ScoreboardElement.kt | 193 ++++++++ .../streaming/overlay/SponsorElement.kt | 24 + .../streaming/overlay/TimerElement.kt | 23 + .../streaming/overlay/WatermarkElement.kt | 35 ++ .../ui/broadcast/BroadcastScreen.kt | 14 + .../app/src/main/res/drawable/logo_white.png | Bin 24398 -> 8293 bytes 29 files changed, 605 insertions(+), 975 deletions(-) delete mode 100644 backend/app/jobs/overlay_refresh_job.rb delete mode 100644 backend/app/jobs/overlay_relay_ensure_job.rb delete mode 100644 backend/app/jobs/overlay_score_refresh_job.rb delete mode 100644 backend/app/services/streams/overlay.rb delete mode 100644 backend/app/services/streams/overlay/badge_label.rb delete mode 100644 backend/app/services/streams/overlay/refresh.rb delete mode 100644 backend/app/services/streams/overlay/svg_builder.rb delete mode 100644 backend/app/services/streams/overlay_relay.rb delete mode 100644 backend/bin/overlay_png_feeder.rb delete mode 100644 backend/lib/tasks/stream_overlay.rake create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayElement.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayRenderer.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/SponsorElement.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb index d6c8fb4..b5d7266 100644 --- a/backend/app/controllers/api/v1/stream_sessions_controller.rb +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -174,7 +174,7 @@ module Api } 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) return if fps < 1 return if @session.terminal? || @session.paused? diff --git a/backend/app/jobs/overlay_refresh_job.rb b/backend/app/jobs/overlay_refresh_job.rb deleted file mode 100644 index 3ef8b9d..0000000 --- a/backend/app/jobs/overlay_refresh_job.rb +++ /dev/null @@ -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 diff --git a/backend/app/jobs/overlay_relay_ensure_job.rb b/backend/app/jobs/overlay_relay_ensure_job.rb deleted file mode 100644 index 6cbb07d..0000000 --- a/backend/app/jobs/overlay_relay_ensure_job.rb +++ /dev/null @@ -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 diff --git a/backend/app/jobs/overlay_score_refresh_job.rb b/backend/app/jobs/overlay_score_refresh_job.rb deleted file mode 100644 index 1c0b602..0000000 --- a/backend/app/jobs/overlay_score_refresh_job.rb +++ /dev/null @@ -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 diff --git a/backend/app/services/mediamtx/client.rb b/backend/app/services/mediamtx/client.rb index 1cd995c..5adc96b 100644 --- a/backend/app/services/mediamtx/client.rb +++ b/backend/app/services/mediamtx/client.rb @@ -12,21 +12,6 @@ module Mediamtx 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) path = session.mediamtx_path_name # record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe diff --git a/backend/app/services/streams/overlay.rb b/backend/app/services/streams/overlay.rb deleted file mode 100644 index 90ddceb..0000000 --- a/backend/app/services/streams/overlay.rb +++ /dev/null @@ -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 diff --git a/backend/app/services/streams/overlay/badge_label.rb b/backend/app/services/streams/overlay/badge_label.rb deleted file mode 100644 index 20c5a55..0000000 --- a/backend/app/services/streams/overlay/badge_label.rb +++ /dev/null @@ -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 diff --git a/backend/app/services/streams/overlay/refresh.rb b/backend/app/services/streams/overlay/refresh.rb deleted file mode 100644 index 685fb7f..0000000 --- a/backend/app/services/streams/overlay/refresh.rb +++ /dev/null @@ -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 diff --git a/backend/app/services/streams/overlay/svg_builder.rb b/backend/app/services/streams/overlay/svg_builder.rb deleted file mode 100644 index 0eae9b6..0000000 --- a/backend/app/services/streams/overlay/svg_builder.rb +++ /dev/null @@ -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 - - - #{scorebug_svg(cols)} - #{badge_svg} - #{brand_watermark_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 - "#{escape(label)}" - 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 ? "" : "" - "#{bg}#{escape(val)}" - 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 ? "" : "" - "#{bg}#{escape(val)}" - end.join - - <<~SVG - - - - #{header_cells} - #{escape(cols[:home_name])} - #{home_pts} - #{escape(cols[:away_name])} - #{away_pts} - - MATCH LIVE TV - - - - - - - - - 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 - - - - 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 - - - #{escape(text)} - - 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 diff --git a/backend/app/services/streams/overlay_relay.rb b/backend/app/services/streams/overlay_relay.rb deleted file mode 100644 index 8a1381c..0000000 --- a/backend/app/services/streams/overlay_relay.rb +++ /dev/null @@ -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 diff --git a/backend/app/services/streams/youtube_relay.rb b/backend/app/services/streams/youtube_relay.rb index dc18b0a..71d8871 100644 --- a/backend/app/services/streams/youtube_relay.rb +++ b/backend/app/services/streams/youtube_relay.rb @@ -116,7 +116,7 @@ module Streams raise Error, "ffmpeg non disponibile: #{e.message}" end - # _air RTMP (copy) se pronto; altrimenti HLS via proxy Rails. + # RTMP grezzo da telefono (overlay bruciato lato app); fallback HLS via proxy Rails. def youtube_ffmpeg_args(intake_source, output) mode, url = intake_source if mode == :rtmp diff --git a/backend/app/views/public/live/index.html.erb b/backend/app/views/public/live/index.html.erb index 428cd3a..a726362 100644 --- a/backend/app/views/public/live/index.html.erb +++ b/backend/app/views/public/live/index.html.erb @@ -61,7 +61,7 @@
<% @sessions.each do |session| %> <% 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) %>
<%= live_match_card_heading(match) %>

diff --git a/backend/app/views/public/live/show.html.erb b/backend/app/views/public/live/show.html.erb index 354627f..65209ca 100644 --- a/backend/app/views/public/live/show.html.erb +++ b/backend/app/views/public/live/show.html.erb @@ -41,7 +41,7 @@ <% else %>

- <%# Tabellone, badge stato e brand sono bruciati nel video (Streams::OverlayRelay). %> + <%# Tabellone e watermark sono bruciati nel video dall'app mobile (overlay client-side). %> diff --git a/backend/bin/overlay_png_feeder.rb b/backend/bin/overlay_png_feeder.rb deleted file mode 100644 index 4ead83a..0000000 --- a/backend/bin/overlay_png_feeder.rb +++ /dev/null @@ -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 diff --git a/backend/lib/tasks/stream_overlay.rake b/backend/lib/tasks/stream_overlay.rake deleted file mode 100644 index 981fae1..0000000 --- a/backend/lib/tasks/stream_overlay.rake +++ /dev/null @@ -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 diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index c4a9abd..65c4feb 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -115,7 +115,6 @@ services: condition: service_started volumes: - recordings:/recordings - - stream_overlays:/app/tmp/stream_overlays healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"] interval: 15s @@ -163,7 +162,6 @@ services: REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-} REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-} REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true} - OVERLAY_RELAY_PROCESS_CHECK: "true" depends_on: rails: condition: service_healthy @@ -171,7 +169,6 @@ services: condition: service_started volumes: - recordings:/recordings - - stream_overlays:/app/tmp/stream_overlays garage: image: dxflrs/garage:v1.0.1 @@ -187,6 +184,5 @@ volumes: postgres_data: redis_data: recordings: - stream_overlays: garage_meta: garage_data: diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt index b02168c..59bf562 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt @@ -2,6 +2,7 @@ package com.matchlivetv.match_live_tv.streaming import android.content.Context import android.os.Build +import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -28,6 +29,10 @@ class LiveBroadcastCoordinator(context: Context) { engine.resumeAfterConfigurationChange() } + fun updateOverlay(state: OverlayState) { + engine.updateOverlay(state) + } + fun startService() { val intent = LiveBroadcastService.startIntent(appContext) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt index 2d503b5..ec22575 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt @@ -10,6 +10,8 @@ import android.view.WindowManager import com.pedro.common.ConnectChecker import com.pedro.library.generic.GenericStream 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.AtomicInteger @@ -39,6 +41,7 @@ class LiveBroadcastEngine( private var currentFps: Int = 0 private val pausingIntentionally = AtomicBoolean(false) private val reconnectAttempts = AtomicInteger(0) + private val overlayRenderer = OverlayRenderer(appContext) private val rebindRunnable = Runnable { reattachPreviewIfNeeded() @@ -56,6 +59,13 @@ class LiveBroadcastEngine( 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) { mainHandler.post { previewView = view @@ -208,6 +218,7 @@ class LiveBroadcastEngine( if (stream.isStreaming) stream.stopStream() if (stream.isOnPreview) stream.stopPreview() } + overlayRenderer.detach() genericStream = null surfaceSuspended = false rebindPending = false @@ -303,10 +314,26 @@ class LiveBroadcastEngine( resetOrientationTracking() applyInitialOrientation() + attachOverlayFilter(cfg) startSensor() 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). */ private fun orientationFromDisplayRotation(): Pair { @Suppress("DEPRECATION") @@ -402,6 +429,7 @@ class LiveBroadcastEngine( lastAppliedRotation = rotation lastAppliedPortrait = isPortrait pendingIsPortrait = isPortrait + config?.let { refreshOverlayLayout(isPortrait) } Log.i(TAG, "$logLabel orientation rotation=$rotation portrait=$isPortrait phase=$phase") }.getOrElse { Log.w(TAG, "$logLabel orientation failed: ${it.message}") diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt new file mode 100644 index 0000000..ae79f7d --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt @@ -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 = 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 } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayElement.kt new file mode 100644 index 0000000..c779f33 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayElement.kt @@ -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) +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt new file mode 100644 index 0000000..ca22854 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt @@ -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 +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayRenderer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayRenderer.kt new file mode 100644 index 0000000..a5226cd --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayRenderer.kt @@ -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" + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt new file mode 100644 index 0000000..c3605ac --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt @@ -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, + 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 +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt new file mode 100644 index 0000000..15cb625 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt @@ -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" + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/SponsorElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/SponsorElement.kt new file mode 100644 index 0000000..4afd5bc --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/SponsorElement.kt @@ -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) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt new file mode 100644 index 0000000..865c7e2 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt @@ -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) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt new file mode 100644 index 0000000..6bb2843 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt @@ -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 + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt index 408b2ca..26ebdf7 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt @@ -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.BroadcastPhase 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.MatchScreenScaffold import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton @@ -187,6 +190,17 @@ fun BroadcastScreen( 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) { while (true) { delay(10_000) diff --git a/native/android/app/src/main/res/drawable/logo_white.png b/native/android/app/src/main/res/drawable/logo_white.png index 64281bcd33961ff979d945ea46b3eca9aeb179e6..94dd73fa2c38c9633854416c6ad9e43001e7d27e 100644 GIT binary patch literal 8293 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 O0rJu+Qk4?MLH_|$eOR&p literal 24398 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 TP!KH$0RU89sVjVugNObPFs;6=