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:
@@ -116,31 +116,30 @@ module Streams
|
||||
raise Error, "ffmpeg non disponibile: #{e.message}"
|
||||
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)
|
||||
mode, url = intake_source
|
||||
if mode == :rtmp
|
||||
return [
|
||||
common_head = [
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
"-fflags", "+genpts+discardcorrupt"
|
||||
]
|
||||
copy_tail = ["-c:v", "copy", "-c:a", "copy", "-f", "flv", output]
|
||||
|
||||
if mode == :rtmp
|
||||
return common_head + [
|
||||
"-analyzeduration", "10000000", "-probesize", "10000000",
|
||||
"-rw_timeout", "15000000",
|
||||
"-i", url,
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
||||
"-bsf:a", "aac_adtstoasc",
|
||||
"-f", "flv", output
|
||||
]
|
||||
"-i", url
|
||||
] + copy_tail
|
||||
end
|
||||
|
||||
[
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
common_head + [
|
||||
"-rw_timeout", "15000000",
|
||||
"-live_start_index", "-1",
|
||||
"-i", url,
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
||||
"-c:a", "copy",
|
||||
"-bsf:a", "aac_adtstoasc",
|
||||
"-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
|
||||
@@ -202,7 +202,7 @@ MediaMTX :1935 — path live/match_{uuid}
|
||||
├──► HLS :8888 ──► edge /hls/ ──► spettatori web
|
||||
└──► (solo platform=youtube)
|
||||
Streams::YoutubeRelay in Sidekiq
|
||||
ffmpeg: -c:v copy, -c:a aac ──► YouTube RTMPS
|
||||
ffmpeg: -c:v copy, -c:a copy ──► YouTube RTMPS
|
||||
```
|
||||
|
||||
| Componente | Ruolo |
|
||||
@@ -210,7 +210,7 @@ MediaMTX :1935 — path live/match_{uuid}
|
||||
| Path dinamici via API :9997 | `Mediamtx::Client` (`create_path`, recording patch, …) |
|
||||
| `alwaysAvailable` + slate | Copertina `/slates/offline.mp4` senza interrompere HLS |
|
||||
| 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 |
|
||||
|
||||
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 ◄──────┘
|
||||
│
|
||||
├── 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.
|
||||
```
|
||||
|
||||
@@ -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 | 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-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono |
|
||||
|
||||
@@ -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**).
|
||||
2. **Pausa / rete assente** → `alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** l’HLS 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`; l’AAC 48 kHz mono è già prodotto dall’app / slate).
|
||||
|
||||
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)
|
||||
└──► (solo platform=youtube)
|
||||
Streams::YoutubeRelay
|
||||
ffmpeg: -c:v copy, -c:a aac ──► RTMPS YouTube
|
||||
ffmpeg: -c:v copy, -c:a copy ──► RTMPS YouTube
|
||||
```
|
||||
|
||||
| Responsabilità | Dove |
|
||||
@@ -93,7 +93,7 @@ La pagina live può mostrare badge HTML di stato (in onda / pausa / attesa); non
|
||||
|
||||
| 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 |
|
||||
| Fine diretta | `UploadFromSession`: concat segmenti (`-c copy`) | Picco breve |
|
||||
| Post-process | `GenerateThumbnail`: un frame → JPEG | Trascurabile |
|
||||
|
||||
@@ -170,7 +170,7 @@ Sull’app nativa l’overlay è composito in GPU **prima** dell’encode RTMP (
|
||||
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).
|
||||
|
||||
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`):
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# YouTube Live — checklist operativa
|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -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 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à
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
Ipotesi: 720p, video copy, AAC mono — **~0.3–0.7 vCPU** medi per relay (picchi più alti su analyze/reconnect).
|
||||
Ipotesi: 720p, remux copy A/V — **~0.15–0.4 vCPU** medi per relay (picchi su analyze/reconnect).
|
||||
|
||||
| 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
|
||||
|
||||
- Secondo MediaMTX / sharding
|
||||
- Audio copy se l’encoder telefono è già YouTube-compatibile (meno CPU)
|
||||
- ~~Audio copy se l’encoder telefono è già YouTube-compatibile (meno CPU)~~ → fatto (app AAC 48k mono + relay `-c:a copy`)
|
||||
- Dedicated più grosso come baseline (spesso più economico dell’overflow cronico ogni weekend)
|
||||
|
||||
---
|
||||
|
||||
@@ -20,8 +20,8 @@ android {
|
||||
applicationId = "com.matchlivetv.match_live_tv"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 28
|
||||
versionName = "2.0.7-native"
|
||||
versionCode = 29
|
||||
versionName = "2.0.8-native"
|
||||
|
||||
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
||||
?: "https://www.matchlivetv.it"
|
||||
|
||||
+11
-1
@@ -1,5 +1,9 @@
|
||||
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(
|
||||
val rtmpUrl: String,
|
||||
val width: Int = 1280,
|
||||
@@ -11,7 +15,13 @@ data class BroadcastConfig(
|
||||
val portrait: Boolean = false,
|
||||
val maxReconnectAttempts: Int = 10,
|
||||
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 {
|
||||
IDLE,
|
||||
|
||||
+5
-1
@@ -316,7 +316,11 @@ class LiveBroadcastEngine(
|
||||
rotation = cfg.rotation,
|
||||
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
|
||||
level = h264Level,
|
||||
) && stream.prepareAudio(48_000, false, cfg.audioBitrate)
|
||||
) && stream.prepareAudio(
|
||||
BroadcastConfig.AUDIO_SAMPLE_RATE_HZ,
|
||||
BroadcastConfig.AUDIO_STEREO,
|
||||
cfg.audioBitrate,
|
||||
)
|
||||
}.getOrElse {
|
||||
Log.w(TAG, "preparePipeline: ${it.message}")
|
||||
false
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// Profilo RTMP allineato a MediaMTX slate + YoutubeRelay (copy A/V).
|
||||
/// Audio: AAC 48 kHz mono 128 kbps.
|
||||
struct BroadcastConfig: Sendable {
|
||||
let rtmpUrl: String
|
||||
var width: Int = 1280
|
||||
@@ -11,6 +13,9 @@ struct BroadcastConfig: Sendable {
|
||||
var portrait: Bool = false
|
||||
var maxReconnectAttempts: Int = 10
|
||||
var reconnectDelayMs: Int = 5_000
|
||||
|
||||
static let audioSampleRateHz: Float64 = 48_000
|
||||
static let audioChannels: UInt32 = 1
|
||||
}
|
||||
|
||||
enum BroadcastPhase: String, Sendable {
|
||||
|
||||
@@ -214,6 +214,14 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
if let microphone = AVCaptureDevice.default(for: .audio) {
|
||||
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) {
|
||||
try await mixer.attachVideo(camera, track: 0) { videoUnit in
|
||||
videoUnit.isVideoMirrored = false
|
||||
@@ -355,10 +363,10 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
var audioSettings = await stream.audioSettings
|
||||
audioSettings = AudioCodecSettings(
|
||||
bitRate: config.audioBitrate,
|
||||
downmix: audioSettings.downmix,
|
||||
channelMap: audioSettings.channelMap,
|
||||
sampleRate: 48_000,
|
||||
format: audioSettings.format
|
||||
downmix: true,
|
||||
channelMap: [0],
|
||||
sampleRate: BroadcastConfig.audioSampleRateHz,
|
||||
format: .aac
|
||||
)
|
||||
try await stream.setAudioSettings(audioSettings)
|
||||
}
|
||||
|
||||
@@ -13,10 +13,17 @@ if ssh "$SERVER" 'command -v rsync >/dev/null 2>&1'; then
|
||||
--exclude '.git' \
|
||||
--exclude 'native/android/build' \
|
||||
--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/tmp' \
|
||||
--exclude 'backend/.bundle' \
|
||||
--exclude 'infra/.env' \
|
||||
--exclude 'infra/garage/garage.prod.toml' \
|
||||
--exclude 'infra/garage/prod-credentials.env' \
|
||||
--exclude 'log/' \
|
||||
--exclude 'node_modules' \
|
||||
"$SRC/" "$SERVER:$DEST/"
|
||||
@@ -24,7 +31,10 @@ else
|
||||
echo "rsync non disponibile sul server, uso tar+scp..."
|
||||
tar -C "$SRC" -czf /tmp/matchlivetv-deploy.tar.gz \
|
||||
--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"
|
||||
ssh "$SERVER" "mkdir -p $DEST && tar -xzf ~/matchlivetv-deploy.tar.gz -C $DEST"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user