Alleggerisce il relay YouTube e rilascia Android 2.0.8.

ffmpeg fa solo remux copy A/V; le app fissano AAC 48 kHz mono; sync deploy non cancella più garage.prod.toml.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 19:25:40 +02:00
co-authored by Cursor
parent 9bc89fceba
commit 7b3256c8a0
14 changed files with 96 additions and 35 deletions
+13 -14
View File
@@ -116,31 +116,30 @@ module Streams
raise Error, "ffmpeg non disponibile: #{e.message}" raise Error, "ffmpeg non disponibile: #{e.message}"
end end
# RTMP grezzo da telefono (overlay bruciato lato app); fallback HLS via proxy Rails. # Remux verso YouTube: video+audio copy (AAC già da app/slate a 48k mono).
# HLS (ADTS) → FLV richiede -bsf:a aac_adtstoasc; RTMP ha già ASC in FLV tags.
def youtube_ffmpeg_args(intake_source, output) def youtube_ffmpeg_args(intake_source, output)
mode, url = intake_source mode, url = intake_source
common_head = [
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt"
]
copy_tail = ["-c:v", "copy", "-c:a", "copy", "-f", "flv", output]
if mode == :rtmp if mode == :rtmp
return [ return common_head + [
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt",
"-analyzeduration", "10000000", "-probesize", "10000000", "-analyzeduration", "10000000", "-probesize", "10000000",
"-rw_timeout", "15000000", "-rw_timeout", "15000000",
"-i", url, "-i", url
"-c:v", "copy", ] + copy_tail
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
"-bsf:a", "aac_adtstoasc",
"-f", "flv", output
]
end end
[ common_head + [
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt",
"-rw_timeout", "15000000", "-rw_timeout", "15000000",
"-live_start_index", "-1", "-live_start_index", "-1",
"-i", url, "-i", url,
"-c:v", "copy", "-c:v", "copy",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1", "-c:a", "copy",
"-bsf:a", "aac_adtstoasc", "-bsf:a", "aac_adtstoasc",
"-f", "flv", output "-f", "flv", output
] ]
@@ -0,0 +1,24 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::YoutubeRelay do
describe ".youtube_ffmpeg_args (private)" do
def args(mode, url)
described_class.send(:youtube_ffmpeg_args, [mode, url], "rtmps://a.rtmps.youtube.com/live2/key")
end
it "remuxes RTMP with video and audio copy (no AAC re-encode)" do
cmd = args(:rtmp, "rtmp://mediamtx:1935/live/match_x")
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-f", "flv")
expect(cmd).not_to include("aac")
expect(cmd.join(" ")).not_to include("-b:a")
end
it "remuxes HLS with AAC bitstream filter for FLV" do
cmd = args(:hls, "http://mediamtx:8888/live/match_x/index.m3u8")
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc", "-f", "flv")
expect(cmd).not_to include("-b:a")
end
end
end
+4 -3
View File
@@ -202,7 +202,7 @@ MediaMTX :1935 — path live/match_{uuid}
├──► HLS :8888 ──► edge /hls/ ──► spettatori web ├──► HLS :8888 ──► edge /hls/ ──► spettatori web
└──► (solo platform=youtube) └──► (solo platform=youtube)
Streams::YoutubeRelay in Sidekiq Streams::YoutubeRelay in Sidekiq
ffmpeg: -c:v copy, -c:a aac ──► YouTube RTMPS ffmpeg: -c:v copy, -c:a copy ──► YouTube RTMPS
``` ```
| Componente | Ruolo | | Componente | Ruolo |
@@ -210,7 +210,7 @@ MediaMTX :1935 — path live/match_{uuid}
| Path dinamici via API :9997 | `Mediamtx::Client` (`create_path`, recording patch, …) | | Path dinamici via API :9997 | `Mediamtx::Client` (`create_path`, recording patch, …) |
| `alwaysAvailable` + slate | Copertina `/slates/offline.mp4` senza interrompere HLS | | `alwaysAvailable` + slate | Copertina `/slates/offline.mp4` senza interrompere HLS |
| Overlay tabellone | **App nativa** (non più ricodifica server) | | Overlay tabellone | **App nativa** (non più ricodifica server) |
| `Streams::YoutubeRelay` | Legge RTMP (o HLS) da MediaMTX; copy video + AAC → YouTube | | `Streams::YoutubeRelay` | Legge RTMP (o HLS) da MediaMTX; remux copy A/V → YouTube |
| `Mediamtx::PublisherSync` | Publisher online/offline, go_live, recording on/off | | `Mediamtx::PublisherSync` | Publisher online/offline, go_live, recording on/off |
Dettaglio pause, player e ruolo ffmpeg: [`LIVE_STREAMING.md`](LIVE_STREAMING.md). Dettaglio pause, player e ruolo ffmpeg: [`LIVE_STREAMING.md`](LIVE_STREAMING.md).
@@ -281,7 +281,7 @@ App Android ◄──── REST API (/api/v1/...) ────► Rails
Sidekiq ◄── Redis ◄──────┘ Sidekiq ◄── Redis ◄──────┘
├── HealthMonitorJob, UploadJob (merge/thumbnail ffmpeg) ├── HealthMonitorJob, UploadJob (merge/thumbnail ffmpeg)
├── YoutubeRelay (ffmpeg copy+AAC, solo dirette YouTube) ├── YoutubeRelay (ffmpeg remux copy A/V, solo dirette YouTube)
└── PublishToYoutubeJob, purge replay, ecc. └── PublishToYoutubeJob, purge replay, ecc.
``` ```
@@ -419,3 +419,4 @@ cd infra && cp .env.example .env && docker compose up -d --build
| 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube da MediaMTX (non via Puma) | | 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube da MediaMTX (non via Puma) |
| 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta | | 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta |
| 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC | | 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC |
| 2026-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono |
+3 -3
View File
@@ -8,7 +8,7 @@ MediaMTX espone **un unico flusso di uscita** per path (`live/match_{uuid}`):
1. **Telefono in onda** → publisher RTMP (camera + **overlay grafico bruciato in app**). 1. **Telefono in onda** → publisher RTMP (camera + **overlay grafico bruciato in app**).
2. **Pausa / rete assente**`alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** lHLS verso il sito. 2. **Pausa / rete assente**`alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** lHLS verso il sito.
3. **YouTube** (solo se `platform=youtube`) → `Streams::YoutubeRelay` in **Sidekiq**: un processo `ffmpeg` legge RTMP/HLS da MediaMTX e inoltra a YouTube (`-c:v copy`, audio ricodificato in AAC). 3. **YouTube** (solo se `platform=youtube`) → `Streams::YoutubeRelay` in **Sidekiq**: un processo `ffmpeg` legge RTMP/HLS da MediaMTX e inoltra a YouTube (`-c:v copy`, `-c:a copy`; lAAC 48 kHz mono è già prodotto dallapp / slate).
Il telefono **non** invia la copertina di pausa: risparmia banda; lo switch slate ↔ camera è lato MediaMTX. Il telefono **non** invia la copertina di pausa: risparmia banda; lo switch slate ↔ camera è lato MediaMTX.
@@ -51,7 +51,7 @@ MediaMTX path live/match_{uuid}
├──► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX ──► player web (HLS.js) ├──► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX ──► player web (HLS.js)
└──► (solo platform=youtube) └──► (solo platform=youtube)
Streams::YoutubeRelay Streams::YoutubeRelay
ffmpeg: -c:v copy, -c:a aac ──► RTMPS YouTube ffmpeg: -c:v copy, -c:a copy ──► RTMPS YouTube
``` ```
| Responsabilità | Dove | | Responsabilità | Dove |
@@ -93,7 +93,7 @@ La pagina live può mostrare badge HTML di stato (in onda / pausa / attesa); non
| Quando | Cosa | Peso CPU | | Quando | Cosa | Peso CPU |
|--------|------|----------| |--------|------|----------|
| Diretta `platform=youtube` | `YoutubeRelay`: demux + **copy video** + AAC audio → RTMPS | Medio-basso **per diretta**, scala con N processi | | Diretta `platform=youtube` | `YoutubeRelay`: demux + **copy video/audio** → RTMPS (remux) | Basso **per diretta**, scala con N processi |
| Diretta solo `matchlivetv` | Nessun ffmpeg in live | Quasi zero | | Diretta solo `matchlivetv` | Nessun ffmpeg in live | Quasi zero |
| Fine diretta | `UploadFromSession`: concat segmenti (`-c copy`) | Picco breve | | Fine diretta | `UploadFromSession`: concat segmenti (`-c copy`) | Picco breve |
| Post-process | `GenerateThumbnail`: un frame → JPEG | Trascurabile | | Post-process | `GenerateThumbnail`: un frame → JPEG | Trascurabile |
+1 -1
View File
@@ -170,7 +170,7 @@ Sullapp nativa loverlay è composito in GPU **prima** dellencode RTMP (
2. `OverlayRenderer``OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente. 2. `OverlayRenderer``OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente.
3. Il bitmap viene applicato come filtro sul flusso RTMP (OpenGL su Android, HaishinKit su iOS). 3. Il bitmap viene applicato come filtro sul flusso RTMP (OpenGL su Android, HaishinKit su iOS).
MediaMTX e `YoutubeRelay` ricevono quindi un flusso già “brandizzato”; il relay YouTube fa solo `-c:v copy` (+ AAC). MediaMTX e `YoutubeRelay` ricevono quindi un flusso già “brandizzato”; il relay YouTube fa solo remux (`-c copy` video e audio).
Elementi grafici (`OverlayCanvasRenderer`): Elementi grafici (`OverlayCanvasRenderer`):
+1 -1
View File
@@ -1,7 +1,7 @@
# YouTube Live — checklist operativa # YouTube Live — checklist operativa
**Aggiornato:** 2026-07-29 **Aggiornato:** 2026-07-29
Pipeline attuale: telefono → MediaMTX → `Streams::YoutubeRelay` (ffmpeg copy+AAC in Sidekiq) → YouTube. Overlay tabellone in **app**, non più `OverlayRelay` server. Pipeline attuale: telefono → MediaMTX → `Streams::YoutubeRelay` (ffmpeg remux copy A/V in Sidekiq) → YouTube. Overlay tabellone in **app**, non più `OverlayRelay` server.
## Verifica rapida su server ## Verifica rapida su server
+1 -1
View File
@@ -7,7 +7,7 @@ Match Live TV supporta due modalità YouTube, legate al piano società:
| **Premium Light** | `matchlivetv_light` | Canale YouTube **Match Live TV** (gestito da te) | | **Premium Light** | `matchlivetv_light` | Canale YouTube **Match Live TV** (gestito da te) |
| **Premium Full** | `team` | Canale YouTube **della società** (OAuth del club) | | **Premium Full** | `team` | Canale YouTube **della società** (OAuth del club) |
Flusso tecnico: telefono (camera + overlay app) → RTMP MediaMTX → `Streams::YoutubeRelay` (ffmpeg `-c:v copy`, AAC) → YouTube Live. Flusso tecnico: telefono (camera + overlay app) → RTMP MediaMTX → `Streams::YoutubeRelay` (ffmpeg `-c:v copy -c:a copy`) → YouTube Live.
--- ---
@@ -29,7 +29,7 @@ Se il telefono andasse diretto a YouTube (o a un ingest cloud senza hold), la li
### Problema di capacità ### Problema di capacità
Ogni diretta `platform=youtube` avvia un processo `ffmpeg` in Sidekiq (`Streams::YoutubeRelay`: video **copy**, audio **AAC**). Ogni diretta `platform=youtube` avvia un processo `ffmpeg` in Sidekiq (`Streams::YoutubeRelay`: video+audio **copy** / remux).
Il carico scala con **N processi**, non con gli spettatori HLS. Il carico scala con **N processi**, non con gli spettatori HLS.
Sul box attuale (~4 CPU / 8 GB, stack completo sullo stesso host): Sul box attuale (~4 CPU / 8 GB, stack completo sullo stesso host):
@@ -299,7 +299,7 @@ Finché D1 non è chiusa, non stimare bandwitdh né SLA del picco.
## 7. Dimensionamento indicativo ## 7. Dimensionamento indicativo
Ipotesi: 720p, video copy, AAC mono**~0.30.7 vCPU** medi per relay (picchi più alti su analyze/reconnect). Ipotesi: 720p, remux copy A/V**~0.150.4 vCPU** medi per relay (picchi su analyze/reconnect).
| Target live YT | Dedicated (relay) | Overflow tipico | Note | | Target live YT | Dedicated (relay) | Overflow tipico | Note |
|----------------|-------------------|-----------------|------| |----------------|-------------------|-----------------|------|
@@ -349,7 +349,7 @@ Questi numeri vanno **calibati** con un load test (N sessioni fake + ffmpeg cont
### Fase 4 — Solo se necessario ### Fase 4 — Solo se necessario
- Secondo MediaMTX / sharding - Secondo MediaMTX / sharding
- Audio copy se lencoder telefono è già YouTube-compatibile (meno CPU) - ~~Audio copy se lencoder telefono è già YouTube-compatibile (meno CPU)~~ → fatto (app AAC 48k mono + relay `-c:a copy`)
- Dedicated più grosso come baseline (spesso più economico delloverflow cronico ogni weekend) - Dedicated più grosso come baseline (spesso più economico delloverflow cronico ogni weekend)
--- ---
+2 -2
View File
@@ -20,8 +20,8 @@ android {
applicationId = "com.matchlivetv.match_live_tv" applicationId = "com.matchlivetv.match_live_tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 28 versionCode = 29
versionName = "2.0.7-native" versionName = "2.0.8-native"
val apiBaseUrl = project.findProperty("API_BASE_URL") as String? val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
?: "https://www.matchlivetv.it" ?: "https://www.matchlivetv.it"
@@ -1,5 +1,9 @@
package com.matchlivetv.match_live_tv.streaming package com.matchlivetv.match_live_tv.streaming
/**
* Profilo RTMP allineato a MediaMTX slate + relay YouTube (copy audio/video).
* Audio: AAC 48 kHz mono 128 kbps — stesso contratto di `infra/scripts/generate_slate.sh`.
*/
data class BroadcastConfig( data class BroadcastConfig(
val rtmpUrl: String, val rtmpUrl: String,
val width: Int = 1280, val width: Int = 1280,
@@ -11,7 +15,13 @@ data class BroadcastConfig(
val portrait: Boolean = false, val portrait: Boolean = false,
val maxReconnectAttempts: Int = 10, val maxReconnectAttempts: Int = 10,
val reconnectDelayMs: Long = 5_000L, val reconnectDelayMs: Long = 5_000L,
) ) {
companion object {
const val AUDIO_SAMPLE_RATE_HZ = 48_000
/** false = mono in RootEncoder `prepareAudio(sampleRate, isStereo, bitrate)`. */
const val AUDIO_STEREO = false
}
}
enum class BroadcastPhase { enum class BroadcastPhase {
IDLE, IDLE,
@@ -316,7 +316,11 @@ class LiveBroadcastEngine(
rotation = cfg.rotation, rotation = cfg.rotation,
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline, profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
level = h264Level, level = h264Level,
) && stream.prepareAudio(48_000, false, cfg.audioBitrate) ) && stream.prepareAudio(
BroadcastConfig.AUDIO_SAMPLE_RATE_HZ,
BroadcastConfig.AUDIO_STEREO,
cfg.audioBitrate,
)
}.getOrElse { }.getOrElse {
Log.w(TAG, "preparePipeline: ${it.message}") Log.w(TAG, "preparePipeline: ${it.message}")
false false
@@ -1,5 +1,7 @@
import Foundation import Foundation
/// Profilo RTMP allineato a MediaMTX slate + YoutubeRelay (copy A/V).
/// Audio: AAC 48 kHz mono 128 kbps.
struct BroadcastConfig: Sendable { struct BroadcastConfig: Sendable {
let rtmpUrl: String let rtmpUrl: String
var width: Int = 1280 var width: Int = 1280
@@ -11,6 +13,9 @@ struct BroadcastConfig: Sendable {
var portrait: Bool = false var portrait: Bool = false
var maxReconnectAttempts: Int = 10 var maxReconnectAttempts: Int = 10
var reconnectDelayMs: Int = 5_000 var reconnectDelayMs: Int = 5_000
static let audioSampleRateHz: Float64 = 48_000
static let audioChannels: UInt32 = 1
} }
enum BroadcastPhase: String, Sendable { enum BroadcastPhase: String, Sendable {
@@ -214,6 +214,14 @@ final class LiveBroadcastEngine: ObservableObject {
if let microphone = AVCaptureDevice.default(for: .audio) { if let microphone = AVCaptureDevice.default(for: .audio) {
try await mixer.attachAudio(microphone, track: 0) try await mixer.attachAudio(microphone, track: 0)
} }
// AAC 48 kHz mono allineato ad Android, slate MediaMTX e YoutubeRelay (-c:a copy).
try await mixer.setAudioMixerSettings(
AudioMixerSettings(
sampleRate: BroadcastConfig.audioSampleRateHz,
channels: BroadcastConfig.audioChannels,
tracks: [0: AudioMixerTrackSettings(downmix: true, channelMap: [0])]
)
)
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) { if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
try await mixer.attachVideo(camera, track: 0) { videoUnit in try await mixer.attachVideo(camera, track: 0) { videoUnit in
videoUnit.isVideoMirrored = false videoUnit.isVideoMirrored = false
@@ -355,10 +363,10 @@ final class LiveBroadcastEngine: ObservableObject {
var audioSettings = await stream.audioSettings var audioSettings = await stream.audioSettings
audioSettings = AudioCodecSettings( audioSettings = AudioCodecSettings(
bitRate: config.audioBitrate, bitRate: config.audioBitrate,
downmix: audioSettings.downmix, downmix: true,
channelMap: audioSettings.channelMap, channelMap: [0],
sampleRate: 48_000, sampleRate: BroadcastConfig.audioSampleRateHz,
format: audioSettings.format format: .aac
) )
try await stream.setAudioSettings(audioSettings) try await stream.setAudioSettings(audioSettings)
} }
+11 -1
View File
@@ -13,10 +13,17 @@ if ssh "$SERVER" 'command -v rsync >/dev/null 2>&1'; then
--exclude '.git' \ --exclude '.git' \
--exclude 'native/android/build' \ --exclude 'native/android/build' \
--exclude 'native/android/.gradle' \ --exclude 'native/android/.gradle' \
--exclude 'native/android/app/build' \
--exclude 'native/android/dist' \
--exclude 'native/android/keystore' \
--exclude 'native/android/keystore.properties' \
--exclude 'native/android/local.properties' \
--exclude 'backend/log' \ --exclude 'backend/log' \
--exclude 'backend/tmp' \ --exclude 'backend/tmp' \
--exclude 'backend/.bundle' \ --exclude 'backend/.bundle' \
--exclude 'infra/.env' \ --exclude 'infra/.env' \
--exclude 'infra/garage/garage.prod.toml' \
--exclude 'infra/garage/prod-credentials.env' \
--exclude 'log/' \ --exclude 'log/' \
--exclude 'node_modules' \ --exclude 'node_modules' \
"$SRC/" "$SERVER:$DEST/" "$SRC/" "$SERVER:$DEST/"
@@ -24,7 +31,10 @@ else
echo "rsync non disponibile sul server, uso tar+scp..." echo "rsync non disponibile sul server, uso tar+scp..."
tar -C "$SRC" -czf /tmp/matchlivetv-deploy.tar.gz \ tar -C "$SRC" -czf /tmp/matchlivetv-deploy.tar.gz \
--exclude='./native/android/build' --exclude='./native/android/.gradle' \ --exclude='./native/android/build' --exclude='./native/android/.gradle' \
--exclude='./backend/log' --exclude='./backend/tmp' --exclude='./infra/.env' --exclude='./.git' . --exclude='./native/android/app/build' --exclude='./native/android/dist' \
--exclude='./native/android/keystore' --exclude='./native/android/keystore.properties' \
--exclude='./backend/log' --exclude='./backend/tmp' --exclude='./infra/.env' \
--exclude='./infra/garage/garage.prod.toml' --exclude='./.git' .
scp /tmp/matchlivetv-deploy.tar.gz "$SERVER:~/matchlivetv-deploy.tar.gz" scp /tmp/matchlivetv-deploy.tar.gz "$SERVER:~/matchlivetv-deploy.tar.gz"
ssh "$SERVER" "mkdir -p $DEST && tar -xzf ~/matchlivetv-deploy.tar.gz -C $DEST" ssh "$SERVER" "mkdir -p $DEST && tar -xzf ~/matchlivetv-deploy.tar.gz -C $DEST"
fi fi