2024. április 28., vasárnap

Gyorskeresés

Boxoljunk MP4-gyel!

Írta: | Kulcsszavak: Go . streaming . OTT . mpeg-dash . smooth streaming . mp4 . média . iptv

[ ÚJ BEJEGYZÉS ]

Rengeteg projekt/ötlet van a TODO listámon, aminek a nagyja időhiány miatt kósza álom marad csupán. Ezekből az ötletekből szoktam válogatni, ha szabadság/pihi van, illetve ha betegség van (mostanában elég ritkán szerencsére). Hát ez most kivételesen egy ilyen nap volt. Ilyenkor jön az, hogy LIFO módon (last in, first out) válogatok belőlük. Vagy ebben az esetben, mivel asszony is szerette volna, kifejthetjük úgy is, hogy last in, female order. ;]

A cikk címe egyébként nem clickbait! Az az egy ember, aki kibírja a cikk végéig, megtudja, miért. :B

Tehát röviden a megoldandó probléma: Foci nézése külföldön. Tudom, hogy kényes téma sokaknál, de szerintem Magyarország egész jól el van látva legalább a sportműsorok egy részével az M4-nek köszönhetően ingyen. Ok, hogy van Eurosport meg társai, de láttam én már Forma1, meg Ferencváros/Újpest/DVSC és <kedvenc klubod jön ide> meccseket szabadon foghatóan.

Namost külföldön ez nem mindig van így. Esetünkben is a helyi focicsapat legtöbb mérkőzését nem közvetíti az állami adó, hanem komoly pénzeket kell TV társaságoknak fizetni, hogy legyen hozzáférésünk. Szerintem ez teljesen rendben van, hiszen a klubokat támogatják a bevétel egy részéből, az már kevésbé elfogadható szerintem, hogy előfizetek egy szolgáltatásra és nem tudom nézni, mert nincs se Android TV, se webes alkalmazásuk.

Ez addig nem volt probléma, amíg LG TV-m volt, viszont amióta egy szoftverfrissítés téglázta, foci nélkül maradtunk. Két éve vettem egy Sony Braviat, mert fényesebb volt a panelje, mint a konkurenciának, számomra elfogadható volt a képe és Android TV-vel jött, ami olyan szempontból előnyösnek tűnt, hogy van sok gyártó, aki Android TV-t rak a készülékeire, ezért van piaca a "nagyok" mellett, de mégsem avul talán úgy el, mint az LG TV-i pl, ahol 1-2 év támogatás után csak ha csúcskategóriás TV-d van kapsz frissítést. A Samsung meg soha nem nyűgözött le teljesítményben és használhatóságban sem.

De ami igazán tetszett a Braviaban az az, hogy támogatja az összes népszerű DRM technológiát. Tudja a Google féle Widevine-t, tudja a Microsoft féle PlayReady-t is és az Apple féle FairPlay-t is. Gondoltam, hogy így be tudom biztosítani, hogy ne kelljen nagyon TV-t váltani, meg a legtöbb streaming szolgáltató boldogulni fog legalább az egyik technológiával a felsorolt háromból.

Képzelhetitek az arcom, mikor szembesültem vele, hogy nincs a TV szolgáltatónak Android TV alkalmazása... :Y No hát ezt add be a szebbik felednek. Hogy mostantól nincs focinézés. Nem, ez egy lehetetlen feladat. ;] Szóval sokáig úgy néztük, hogy felraktam az Android alkalmazást emulátorba, pár device property-t átírtam, hogy fizikai eszköznek gondolja az alkalmazás a virtuális gépet, majd HDMI-n ment a TV-re a jel és néztük. Ez azért működhetett, mert a többi naggyal ellentétben ők nem valami ismert hardveres DRM megoldást használnak, mint a PlayReady SL3000, vagy a Widevine L1 Androidon, hanem egy teljesen szoftveresen megvalósított DRM megoldást. Ez egy bevett szokás, a Vodafone is előszeretettel alkalmazza ezt a megoldást Androidon, csak nagrával. Érthető, mert a Widevine elég sok viszontagságot megélt már és egyre többet hallani, hogy törik, a PlayReady pedig csak pár Android TV által van támogatva. Azt meg nyilván nem teheti meg a szolgáltató kisebb cég révén, hogy dedikált chipet ad neked, amit felpattintasz a mobilod hátára és majd az biztosítja a lopásvédelmet. Szóval a kulcsok mindenképpen az eszköz memóriájában vannak, mert semmi speciális jogot nem kapnak ezek a szoftverek, olyan szinten futnak, mint bármi egyéb app. A kulcsot és a dekódoló algoritmust viszont igyekszenek elrejteni a kalózok elől, ezért brutális mennyiségű obfuszkáció és egyéb trükkök vannak alkalmazva, hogy ne legyen egyértelmű egy külső szemlélőnek, hogy mit hogy csinál az app. Illetve a legtöbb esetben a hardveres gyorsítást / GPU-s transzkódolást el is lehet felejteni, mindent szoftveresen csinálnak. Ebből következik, hogy piszok nagy az erőforrásigénye a dolognak és lassú. Meg emulátorban bugos is volt. Aztán egyik napról a másikra fekete lett a kép. Bevezették a HDCP ellenőrzést, szóval nem is nézhetted olyan monitoron a streamet, ami nem volt támogatva. A virtuális gépet pedig el lehetett felejteni. A problémáról többek közt XDA topik is készült.

A legtöbb Android alkalmazást egyébként el lehet indítani TV-ken is, max az érintőképernyő hiánya miatt érintés emulációval kell majd vezérelni, meg a szenzorokkal be kell kamuzni, hogy döntve van a telefon, hogy teljes képernyőre váltson a stream, de a kérdéses alkalmazásnál ezt nagyon nem akarták. Szóval le van tiltva a lehetőség is. Megtehettem volna, hogy akkor valamennyire szétkapom az alkalmazást és írok egy TV barát felületet, ami ugyanúgy hívja a DRM könyvtárat, mint a gyári app, hiszen így nincs szükség DRM törésre, de nem tudom ez mennyire szürke zóna jogilag, illetve hamar elvetettem az ötletet, mikor megláttam, hogy a DRM könyvtár ellenőrzi az őt futtató alkalmazás digitális aláírását és ha bármit is módosítottnak talál, akkor többé nem indul el. A visszafejtés meg aztán végképp nem jöhet szóba, mert 1.) essen neki az, akinek két anyja sok szabadideje van, 2.) a hűvösön biztosan nem lehet focit nézni, 3.) fürdős típus vagyok, nem szeretek zuhanyozni.

Ja és az Android app HLS streamet kap, ami le van butítva max HD felbontásra és elég gyenge bitrátára, mert gondolom nem gondolják, hogy egy telefon méretű kijelzőre kellene nagyobb és nem bíznak maximálisan a szoftveres DRM megoldás lopásgátló képességeiben. Full HD adást csak a TV-s appokon láttam eddig.

De mi akkor a megoldás? Vegyek megint LG-t? Akkor már inkább veszek éves stadionbérletet, mert azzal jobban járok évekig... Mondjam le a szolgáltatást? Jó, igen, igazad van. Vaaaagy....

Hát igen, szóval ez lett belőle. De ne szaladjunk ennyire előre. Szóval ott tartottunk, hogy az LG és Samsung TV appok tudtak Full HD streamet. Mivel Samsung TV-t még csak kölcsönbe se tudtam szerválni, meg nem igazán hekker barát egyik sem, maradt az LG app. Szereztem kölcsönbe egy TV-t tesztelni. A legtöbb LG TV-s alkalmazás igazából egy webapp, ami esetlegesen kiszól a webes kontextusból a médialejátszónak, meg egyéb komponenseknek, de mivel egy módosított és régi Chromium fork fut az összes TV-n, amivel eddig találkoztam, működik az a megoldás, hogy az alkalmazás adatai közé beírva, hogy isdebuggable: true, távolról egy Chrome böngésző segítségével lehet debuggolni az alkalmazást. Sajna a playback nem ment így a gépemen, de arra jó volt ez a gyors session, hogy megtudtam, papírforma PlayReady-t használnak a TV-s alkalmazásban! Szereztem manifest linkeket is a csatornákhoz. Ezeket az adatokat beadtam Windowson egy webes lejátszónak és ment...

...vagyis a fenéket ment. Túl egyszerű lett volna. Szóval maradt az az ötlet, hogy én biza a TV-men fogom nézni a streamet. PlayReady-t támogat, szóval mennie kéne. Kivéve persze, ha külön csak az LG és Samsung TV-ket engedélyezték a szerveren. Gondoltam ezt könnyű lesz megerősíteni, hiszen írok egy Kodi plugint gyorsba, ami indít egy lejátszást. Szerencsére a manifest formátumát pont támogatja a Kodi-s inputstream adaptive kiegészítő, így csak beadtam a streamet és imádkozva nyomtam a play gombra. És ment...

..vagyis a fenéket ment! Bárhogy próbálkoztam, azt a szűkszavú visszajelzést kaptam csak, hogy nem tudta dekódolni a streamet. Az viszont látszott a logokból, hogy a licenc kérés sikeresen megtörtént, csak a dekódolásnál ment félre valami. De az elérhető minőségeket, meg minden más metaadatot szépen felismert.

Egyből neki is álltam a hibaelhárításnak. Ahhoz, hogy ezt megtegyük, először fontos megérteni, hogy mik is ezek a manifest fájlok és mi a szerepük nagyjából. Ma már a legtöbb streaming szolgáltató a Netflixtől és társaitól elkezdve az Amazonon át a Skyshowtime-ig ilyet használ, ahelyett, hogy egy hagyományos, egy fájlos hatalmas MP4 fájlt továbbítana folyamatosan a klienseknek. Lényegében ez egy leíró fájl, ami tartalmazhatja az adott média adatait, illetve a média fel van darabolva részekre, amik technológiától függően hallgatnak a chunk/fragment/segment névre, a manifest pedig azt mondja meg, hogy ezek a szegmensek milyen hosszúak, illetve hogy hol találhatóak és mennyi elérhető belőlük. A kliensek pedig on-demand módon tudják az apró szeleteket igény esetén fokozatosan letölteni, ezzel is spórolva az adatforgalmon. Illetve van lehetőség a manifestben több minőséghez külön szeleteket megadni. Emiatt tud visszaváltani a minőségen automatikusan az összes szolgáltató. Lehetőség van több hangcsatornát elérhetővé tenni, de a kliens így is csak az éppen szükséges hangot fogja leszedni stb. Szóval megannyi előnye van. Élő adásoknál pedig az az előnye is megvan, hogy elég ezt a pár kilobyte méretű manifestet lekérni, majd az megmondja, hogy éppen mekkora része elérhető az adásnak.

Ezekre mind vannak standardok, amik szépen megmondják, hogy az adott manifest típus hogy nézzen ki. Van pár ismert, pl az Apple féle HLS. Ő személyes véleményem szerint lehet sokkal egyszerűbb is, mint a másik kettő testvére és nem XML alapú. De vannak korlátai, amit az Apple igyekszik a verziókkal feloldani. Viszont emiatt ahány player, annyi megoldás létezik. Akkor van az MPEG-DASH. A legtöbb streaming ezt használja, mert szofisztikáltabbnak indult, mint a HLS és egy viszonylag jól dokumentált és széleskörűen támogatott szabvány. A legtöbb lejátszó támogatja. És akkor van a Microsoft féle Smooth Streaming, vagy MSS, aminek még külön wikipédia oldala sincs. Kicsit hasonló, mint az MPEG-DASH. Ugyanúgy fragmentált MP4-eket (fmp4) támogat főként, XML alapú, de jóval kevésbé támogatott, szinte alig találtam lejátszót, ami kezelte volna. PlayReady-t is támogató lejátszót pedig pár böngészős kivételével egyet sem. Régen a Silverlight volt Windowson, most nem tudom mit használnak rá.

A lényeg, hogy esetünkben a szolgáltató nyilván az utóbbit használta, csak hogy nehezebb legyen a dolgunk még ezzel is. :D Megjegyzem, hogy ezen kívül nem találkoztam még szolgáltatóval, aki MSS-t preferálna. Gondolom ez is afféle védelmi döntés volt a kalózok ellen, hogy úgyse fog senki szenvedni azzal, hogy portolja.

Feltételeztem, hogy ez az első lépés, amit a hivatalos LG app is lekér, szóval mindenképp ennek a feldolgozásával kell kezdenem a kutatást. Bevallom férfiasan, hogy korábban nekiálltam egyszer a problémának, akkor rustban kezdtem el a projektet, a manifest feldolgozás része meg is volt a dolognak, de később az init szegmensnél csúnyán elakadtam. Rengeteg munka lett volna a nulláról lefejleszteni (majd a továbbiakban kiderül, miért), ezért inkább elengedtem akkor a dolgot. Aztán a Pythonnal próbálkoztam, de az sem tűnt ideális választásnak, mert lomha volt és nem találtam rendes MP4 könyvtárat hozzá akkor. Végül most Go-ban implementáltam, így azt a struktúrát tudom megmutatni. Nagyon nem akarok kódokkal ijesztegetni senkit, de talán ezt bemásolom. Nyugodtan át lehet pörgetni:

type SmoothStream struct {
XMLName xml.Name `xml:"SmoothStreamingMedia"`
MajorVersion int `xml:"MajorVersion,attr"`
MinorVersion int `xml:"MinorVersion,attr"`
Duration int `xml:"Duration,attr"`
TimeScale int `xml:"TimeScale,attr"`
IsLive bool `xml:"IsLive,attr"`
LookAheadFragmentCount int `xml:"LookAheadFragmentCount,attr"`
DVRWindowLength int `xml:"DVRWindowLength,attr"`
CanSeek bool `xml:"CanSeek,attr"`
CanPause bool `xml:"CanPause,attr"`
Protection []SmoothProtectionHeader `xml:"Protection>ProtectionHeader"`
StreamIndexes []StreamIndex `xml:"StreamIndex"`
}

type SmoothProtectionHeader struct {
XMLName xml.Name `xml:"ProtectionHeader"`
SystemID string `xml:"SystemID,attr"`
CustomData string `xml:",chardata"`
}

type StreamIndex struct {
XMLName xml.Name `xml:"StreamIndex"`
Type string `xml:"Type,attr"`
Name string `xml:"Name,attr"`
Language string `xml:"Language,attr"`
Subtype string `xml:"Subtype,attr"`
Chunks int `xml:"Chunks,attr"`
TimeScale int `xml:"TimeScale,attr"`
Url string `xml:"Url,attr"`
QualityLevel []QualityLevel `xml:"QualityLevel"`
ChunkInfos []ChunkInfos `xml:"c"`
}

type QualityLevel struct {
XMLName xml.Name `xml:"QualityLevel"`
Index int `xml:"Index,attr"`
Bitrate int `xml:"Bitrate,attr"`
CodecPrivateData string `xml:"CodecPrivateData,attr"`
FourCC string `xml:"FourCC,attr"`
MaxWidth int `xml:"MaxWidth,attr"`
MaxHeight int `xml:"MaxHeight,attr"`
AudioTag int `xml:"AudioTag,attr"`
Channels int `xml:"Channels,attr"`
SamplingRate int `xml:"SamplingRate,attr"`
BitsPerSample int `xml:"BitsPerSample,attr"`
PacketSize int `xml:"PacketSize,attr"`
}

type ChunkInfos struct {
XMLName xml.Name `xml:"c"`
Duration int64 `xml:"d,attr"`
StartTime int64 `xml:"t,attr"`
}

Szóval van egy XML fájl, amiben van egy SmoothStreamingMedia objektum. Ezen belül van egy ProtectionHeader mező, ami gyakorlatilag a PlayReady PSSH. Akit érdekel a PSSH, az DASH kapcsán megtalálja, hogy mi ez pl itt. Smooth streaming esetében is ugyanaz a PSSH, mint DASH esetében. Gyakorlatilag ez mondja meg a DRM motornak, hogy milyen kérést indítson és mihez kérjen kulcsot a szervertől. Aztán vannak a StreamIndexek. Ez pl lehet videó, hang és felirat. Egy StreamIndex pl így nézhet ki:

<StreamIndex Type="video" Name="video" Language="und" Subtype="" Chunks="0" TimeScale="10000000" Url="QualityLevels({bitrate})/Fragments(video={start time})">...

Aztán ezen a StreamIndexen belül találhatóak a QualityIndexek, pl:

<QualityLevel Index="0" Bitrate="90000" CodecPrivateData="00000001674d40209e52820276028404040500000300010000030064e000057e000afc3f13e0a00000000168ef7520" FourCC="AVC1" MaxWidth="256" MaxHeight="144"/>

Igazából eddig a legtöbb mező magáért beszélt, kivéve talán a CodecPrivateDatat.

Ja és van a StreamIndexen belül még egy érdekesség:

<c d="20000000" t="4096811295434096"/><c d="20000000"/>

Itt az a lényeg, hogy az első elérhető chunk a 4096811295434096-nál elérhető időben, illetve 20000000 hosszú. Mivel fentebb a Timescale 10000000 volt, ez 2 másodpercet jelent. Tehát az első szegmens / videó darab 2s hosszú. A másodiknál nincs megadva a t, csak az, hogy ez 2s hosszú, tehát feltételezhetjük, hogy a második majd a 4096811295434096 + 20000000 bonyolult összeadás eredményén lesz elérhető.

A szegmensek linkje pedig úgy fog kinézni, hogy volt nekünk fentebb a StreamIndexnél egy ilyen: QualityLevels({bitrate})/Fragments(video={start time}). Ha én a 256x144px-es QualityLevelű streamet akarom nézni, akkor a {bitrate} helyére 90000.t kell írjak, hiszen ez volt a QualityLevel bitrátája, illetve az aktuális id megy a {start time} helyére. Az első chunk pl ebben a példában a QualityLevels(90000)/Fragments(video=4096811295434096) volt. Ezt hozzáfűzve a manifest linkhez szépen le tudjuk kérni a szegmenst. Szerintem ezt túltárgyaltuk.

Van egy parserünk, ami le tudja tölteni a manifestet és fel tudja dolgozni. Chunkokat is tudok letölteni. Töltsünk is le egyet és nézzünk rá, hogy ez mégis mi a fene lehet:

[steve@todo test_sport]$ file video/video_0_enc.mp4
video/video_0_enc.mp4: data

Még a file parancs sem ismeri fel. Jól kezdődik. Nézzünk egy mediainfot:

[steve@todo test_sport]$ mediainfo video/video_0_enc.mp4
General
Complete name : video/video_0_enc.mp4
Format : QuickTime
Format/Info : Original Apple specifications
File size : 1.89 MiB
FileExtension_Invalid : braw mov qt

Ez se valami beszédes, de annyit lát, hogy valami média lehet. Ezzel szemben egy megszokott MP4 fájl esetében jóval beszédesebb a tool:

General
Complete name : /home/steve/Downloads/warnerbros.mp4
Format : MPEG-4
Format profile : Base Media
Codec ID : isom (isom/iso2/avc1/mp41)
File size : 2.31 MiB
Duration : 14 s 722 ms
Overall bit rate : 1 317 kb/s
Frame rate : 25.000 FPS
Writing application : Lavf58.44.100

Video
ID : 1
Format : AVC
Format/Info : Advanced Video Codec
Format profile : High@L3.1
Format settings : CABAC / 4 Ref Frames
Format settings, CABAC : Yes
Format settings, Reference frames : 4 frames
Codec ID : avc1
Codec ID/Info : Advanced Video Coding
Duration : 14 s 720 ms
Bit rate : 1 182 kb/s
Width : 1 280 pixels
Height : 720 pixels
Display aspect ratio : 16:9
Frame rate mode : Constant
Frame rate : 25.000 FPS
Color space : YUV
Chroma subsampling : 4:2:0
Bit depth : 8 bits
Scan type : Progressive
Bits/(Pixel*Frame) : 0.051
Stream size : 2.07 MiB (90%)
Title : ISO Media file produced by Google Inc.
Writing library : x264 core 160 r3000 33f9e14
Encoding settings : cabac=1 / ref=3 / deblock=1:0:0 / analyse=0x3:0x113 / me=hex / subme=7 / psy=1 / psy_rd=1.00:0.00 / mixed_ref=1 / me_range=16 / chroma_me=1 / trellis=1 / 8x8dct=1 / cqm=0 / deadzone=21,11 / fast_pskip=1 / chroma_qp_offset=-2 / threads=22 / lookahead_threads=3 / sliced_threads=0 / nr=0 / decimate=1 / interlaced=0 / bluray_compat=0 / constrained_intra=0 / bframes=3 / b_pyramid=2 / b_adapt=1 / b_bias=0 / direct=1 / weightb=1 / open_gop=0 / weightp=2 / keyint=250 / keyint_min=25 / scenecut=40 / intra_refresh=0 / rc_lookahead=40 / rc=crf / mbtree=1 / crf=28.0 / qcomp=0.60 / qpmin=0 / qpmax=69 / qpstep=4 / ip_ratio=1.40 / aq=1:1.00
Codec configuration box : avcC

Audio
ID : 2
Format : AAC LC
Format/Info : Advanced Audio Codec Low Complexity
Codec ID : mp4a-40-2
Duration : 14 s 722 ms
Bit rate mode : Constant
Bit rate : 128 kb/s
Channel(s) : 2 channels
Channel layout : L R
Sampling rate : 44.1 kHz
Frame rate : 43.066 FPS (1024 SPF)
Compression mode : Lossy
Stream size : 230 KiB (10%)
Title : ISO Media file produced by Google Inc.
Default : Yes
Alternate group : 1

Mint megtudtam, ez azért van, mert minden MP4 eleje tartalmaz egy metadata szekciót, ami tartalmazza az összes player számára érdekes információt. Hogy milyen trackek vannak, milyen a felbontásuk, mivel kódolták a videót, meg még sok ezer más dolgot. De tényleg! Elképesztő mennyiségű adatot tudunk egy MP4 fejlécbe sűríteni. Namost ezt a fejlécet tök felesleges lenne minden szegmens elé odafűzni, szóval DASH esetében pl bevett szokás az, hogy van egy initialization mező az XML-ben, ami egy apró mp4 fájlra mutat. Ezt a kliens a lejátszás indulásakor letölti, értelmezi, majd eltárolja a teljes lejátszás idejére. A felbontás, meg a többi ilyen metaadat úgysem változik a legtöbb esetben, szóval ez egy jó megoldás.

Igen ám, de itt ennyi se volt. Mint kiderült smooth streamingnél nincs olyan, hogy a szerver ad egy init szegmenst, letöltjük és boldogok vagyunk. Nem, azt magunknak kell összerakni. Na és ezen a ponton éreztem azt, hogy ehhez én nagyon kevés vagyok. Semmit nem tudok a smooth streamingről, az MP4-ről meg aztán még kevesebbet. Mikor először nekiálltam, itt engedtem el a dolgot hónapokig. Aztán most újra elővettem, mert az utóbbi időben rengeteget játszottam különféle fájlok alacsony szintű összerakásával főleg erlangban, hogy frissítsem kicsit a tudásom. Úgy éreztem, most össze fogom tudni hozni.

Ezt azonban tényleg nem erlangban kezdtem el, mert nem akartam interpretált nyelvet, hanem valamit, ami binárisra fordul és támogatja majd a párhuzamosságot, mert nem titkolt célom volt a család többi tagjával is megosztani az esetleges végterméket. A rustos megoldást sajnos elvetettem, mert marha időigényes volt és gyorsan kellett a fix, szóval Go-ban kezdtem el a mókát. Ez egész gyors, binárisra fordul és egész könnyű Raspberry Pi-re fordítani vele. Ezen akartam ugyanis hosztolni.

Nekiálltam smooth streamingre libraryt keresni, hogy ne kelljen már feltalálni a spanyolviaszt, ha megírta már valaki más, de az az igazság, hogy alig találtam könyvtárat erre a formátumra. Hogy miért, azt nem tudom. Feltételezem, hogy MS proprietary, aztán nem szeretik a napvilágot, vagy én nem tudom. A yt-dlp projektben találtam egy érdekesnek tűnő kódot: [link].

Az elsőre világosnak tűnt, hogy a write_piff_header fogja nekem az init szegmenst előállítani, de nem igazán van dokumentálva, hogy akkor mi mit csinál és első ránézésre gatyát kellett cseréljek, mert kb egy szót nem értettem, hogy akkor ez most mi akar lenni.

Egyébként valahogy kimatekoztam, hogy ezekkel a paraméterekkel:

tfhd_data = extract_box_data(data, [b"moof", b"traf", b"tfhd"])
write_piff_header(f, {
"track_id": u32.unpack(tfhd_data[4:8])[0],
"fourcc": "AVC1",
"duration": 20000000,
"timescale": 10000000,
"language": "und",
"height": 1920,
"width": 1080,
"is_drm_protected": True,
"kid": bytes.fromhex("CSATORNA KEYID VOLT ITT"),
"codec_private_data": "00000001674d40209e52820276028404040500000300010000030064e000057e000afc3f13e0a00000000168ef7520",
"channels": 0,
"bits_per_sample": 0,
"sampling_rate": 0,
"bitrate": 7830000,
"nal_unit_length_field": 0,
})

előáll nekem valami init szegmens, de nagyon zavart, hogy a felét nem értem a kódnak, illetve nem voltam benne biztos, hogy ez a kód biztos minden eshetőségre jó-e. Spoiler: nem. Messze nem tökéletes megoldás.

Oké, akkor mi a fenét tudok csinálni? Elkezdtem kutatni a neten és belefutottam egy DashMe névre hallgató projektbe. Érdekes amúgy, mert látszólag ugyanaz a célja kb, mint nekem. Átcsomagolni az MSS-t DASH formába. Viszont ezer éves a kód és a mostani ffmpeg és linux verziókon nem is fordul. Nagy nehezen sikerült működésre bírni egy régebbi Ubuntu dockerben úgy, hogy jelentősen átírtam a kódot, de még így is kényelmetlen volt, hogy ezért egy régi linuxot kell futtassak és a fele kódot nem értem. Meg nem teljesen pure Go a cucc, hanem az ffmpeges libavformatot is használja, ami nem rossz, de külső függőség, ami macera. Inspirációnak viszont nagyon sokat segített, ha bizonytalan voltam valami kapcsán. Ebben egyébként van egy hasonló atom builder, mint a korábbi Python megoldásban: [link]. De ez se valami dokumentált, meg nem teljes.

Kerestem hát valami olyan Go libet, amivel végre tudok MP4-et csinálni. Találtam is. Ime, a go-mp4: [link]. Csak épp a dokumentációja nem az igazi, fogalmam se volt, hogy mit csinálok, éppen ezért nem sikerült kiigazodnom rajta.

Találtam viszont egy másik libet, ami az mp4ff nevet kapta: [link]. Nos, ennek se valami részletes a doksija, de legalább vannak példák és parancsok, amik kb lefedik a use-casem. Az egyik ilyen az initcreator példa, ami gyakorlatilag egy init szegmenst készít a nulláról, a másik pedig az mp4-decrypt parancs, amivel meg ilyen DRM védett médiákat tudunk dekódolni: [link]. Nem tudom érzitek-e, de azért ez egy jelentős löketet ad a korábbi sötétben tapogatózáshoz! Kezd kirajzolódni az út.

Illetve amit még nagyon tudok ajánlani a formátum megértésére, az a Bento4 szoftvercsomag mp4dump parancsa.

Ami nekem még sokat segített, az ez a cikk volt: [link]

De ezekre úgyis kitérek majd a következőekben.

Szóval azt tudtam, hogy egy init szegmenst kell készítenem. Elkezdtem bámulni a példát, először a main metódussal:

func main() {

err := writeVideoAVCInitSegment()
if err != nil {
log.Fatalln(err)
}
err = writeVideoHEVCInitSegment()
if err != nil {
log.Fatalln(err)
}
err = writeAudioAACInitSegment()
if err != nil {
log.Fatalln(err)
}
[...]

Megnéztem, hogy az én videóm AVC lesz, mivel a manifestben szerepelt egy FourCC="AVC1" sor, szóval ezt a függvényt kezdtem el bámulni:

func writeVideoAVCInitSegment() error {
sps, _ := hex.DecodeString(avcSPSnalu)
spsNALUs := [][]byte{sps}
pps, _ := hex.DecodeString(avcPPSnalu)
ppsNALUs := [][]byte{pps}

videoTimescale := uint32(180000)
init := mp4.CreateEmptyInit()
init.AddEmptyTrack(videoTimescale, "video", "und")
trak := init.Moov.Trak
includePS := true
err := trak.SetAVCDescriptor("avc1", spsNALUs, ppsNALUs, includePS)
if err != nil {
return err
}
width := trak.Mdia.Minf.Stbl.Stsd.AvcX.Width
height := trak.Mdia.Minf.Stbl.Stsd.AvcX.Height
if width != 1280 || height != 720 {
return fmt.Errorf("Did get %dx%d instead of 1280x720", width, height)
}
err = writeToFile(init, "video_avc_init.cmfv")
return err
}

Elég egyszerűnek tűnik és a library láthatóan le is veszi a vállunkról azt a terhet, hogy nagyon tüzetesebben meg kellene értenünk az MP4 részleteit, mert egy szerintem tök barátságos API-n keresztül megoldható jó része a dolgoknak. Csak az nem volt világos, hogy mi a fene az az sps és pps, meg honnan szerzek én ilyeneket. Erre ez a blog adta meg a választ: [link] De mire megtaláltam... :DD

Viszont ezzel nem lettem nagyon előrébb. Honnan a fenéből álmodjam én ezt meg?! Erre végül a yt-dlp féle ism implementációban leltem meg a választ.

Szóval van ez a CodecPrivateData="00000001674d40209e52820276028404040500000300010000030064e000057e000afc3f13e0a00000000168ef7520" dolog a smooth streaming manifestben. Ezen fut az alábbi Python kód:

sps, pps = codec_private_data.split(u32.pack(1))[1:]

Igazából elég egyértelmű a hack, de demonstrálom terminállal is:

[steve@todo test_sport]$ python3
Python 3.11.7 (main, Jan 29 2024, 16:03:57) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import struct
>>> u32 = struct.Struct('>I')
>>> u32.pack(1)
b'\x00\x00\x00\x01'
>>>

Szóval fel kell vágni a 0x00, 0x00, 0x00, 0x01 szekvenciánál a bemeneti hexa adatot, aztán el kell hagyni az elejét és ami kettő adat marad, az az sps és a pps.

Nézzük akkor Pythonban a konkrét esetünket:

[steve@todo test_sport]$ python3
Python 3.11.7 (main, Jan 29 2024, 16:03:57) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import struct, binascii
>>> u32 = struct.Struct('>I')
>>> codec_private_data = "00000001674d40209e52820276028404040500000300010000030064e000057e000afc3f13e0a00000000168ef7520"
>>> codec_private_data = binascii.unhexlify(codec_private_data.encode())
>>> sps, pps = codec_private_data.split(u32.pack(1))[1:]
>>> sps
b'gM@ \x9eR\x82\x02v\x02\x84\x04\x04\x05\x00\x00\x03\x00\x01\x00\x00\x03\x00d\xe0\x00\x05~\x00\n\xfc?\x13\xe0\xa0'
>>> pps
b'h\xefu '
>>> sps.hex()
'674d40209e52820276028404040500000300010000030064e000057e000afc3f13e0a0'
>>> pps.hex()
'68ef7520'
>>>

Ugyanez Go-ban:

func main() {
codecPrivateData, _ := hex.DecodeString("00000001674d40209e52820276028404040500000300010000030064e000057e000afc3f13e0a00000000168ef7520")
delimiter := []byte{0, 0, 0, 1}
split := bytes.SplitN(codecPrivateData, delimiter, 3)
if len(split) != 3 {
log.Fatal("invalid codecPrivateData")
}
sps := split[1]
pps := split[2]
log.Printf("sps: %s", hex.EncodeToString(sps))
log.Printf("pps: %s", hex.EncodeToString(pps))
}

[steve@todo test]$ go run main.go
2024/02/25 02:14:06 sps: 674d40209e52820276028404040500000300010000030064e000057e000afc3f13e0a0
2024/02/25 02:14:06 pps: 68ef7520

Remek! Egyeznek.

Ugorjunk egy nagyot most a mélyvízbe és próbáljuk meg alkalmazni a korábban elegánsan kiszemelt példakódot picit átalakítva alkalmazni:

func main() {
codecPrivateData, _ := hex.DecodeString("00000001674d40209e52820276028404040500000300010000030064e000057e000afc3f13e0a00000000168ef7520")
delimiter := []byte{0, 0, 0, 1}
split := bytes.SplitN(codecPrivateData, delimiter, 3)
if len(split) != 3 {
log.Fatal("invalid codecPrivateData")
}

spsNALUs := [][]byte{split[1]}
ppsNALUs := [][]byte{split[2]}

videoTimescale := uint32(10000000)
init := mp4.CreateEmptyInit()
init.AddEmptyTrack(videoTimescale, "video", "und")
trak := init.Moov.Trak
err := trak.SetAVCDescriptor("avc1", spsNALUs, ppsNALUs, true)
if err != nil {
log.Fatal(err)
}
width := trak.Mdia.Minf.Stbl.Stsd.AvcX.Width
height := trak.Mdia.Minf.Stbl.Stsd.AvcX.Height
log.Printf("Width: %d, Height: %d", width, height)

outFile, err := os.Create("init.mp4")
if err != nil {
log.Fatal(err)
}
defer outFile.Close()
err = init.Encode(outFile)
if err != nil {
log.Fatal(err)
}
}

Annyit csináltam, hogy szerkesztettem a pps és sps értékeket a felfedezés alapján, illetve a manifestben a Timescale érték 10000000 volt, tehát ezt megadtam. A kódot futtatva pedig a következő történik:

[steve@todo test]$ go run main.go
2024/02/25 02:16:15 Width: 256, Height: 144

Azért ez barátságos, nem? Nem is adtunk meg direktben felbontást, de a pps és sps alapján kitalálta. És a library a dolgok nehezét le is vette a vállunkról (eddig).

Nézzük csak, hogy mit kaptunk:

[steve@todo test]$ mediainfo init.mp4
General
Complete name : init.mp4
Format : cmfc
Codec ID : cmfc (dash/iso6)
File size : 672 Bytes
Overall bit rate mode : Variable
Frame rate : 50.000 FPS

Video
ID : 1
Format : AVC
Format/Info : Advanced Video Codec
Format profile : Main@L3.2
Format settings : CABAC / 4 Ref Frames
Format settings, CABAC : Yes
Format settings, Reference frames : 4 frames
Codec ID : avc1
Codec ID/Info : Advanced Video Coding
Bit rate mode : Variable
Maximum bit rate : 90.0 kb/s
Width : 256 pixels
Height : 144 pixels
Display aspect ratio : 16:9
Frame rate : 50.000 FPS
Standard : Component
Color space : YUV
Chroma subsampling : 4:2:0
Bit depth : 8 bits
Scan type : Progressive
Color range : Limited
Color primaries : BT.709
Transfer characteristics : BT.709
Matrix coefficients : BT.709
Codec configuration box : avcC

Marha profi. Felismerte a bitrátát, FPS-t, meg egy csomó más adatot csupán az sps-ből. Viszont ez sajnos nem lesz elég. A legfőbb probléma az, hogy nincs jelezve, hogy ez egy titkosított média init szegmense.

Van egy példa fájl a repóban, amiben nem vagyok biztos, hogy legális-e, mert tartalmazza a visszafejtett verziót is, de arra nekem jó volt, hogy meg tudtam nézni, hogyan is néz ki egy megfelelően összerakott hasonló szegmens:

[steve@todo test]$ mediainfo /tmp/mp4ff/cmd/mp4ff-decrypt/testdata/PIFF/video/complseg-1.0001.mp4
General
Complete name : /tmp/mp4ff/cmd/mp4ff-decrypt/testdata/PIFF/video/complseg-1.0001.mp4
Format : dash
Codec ID : dash (iso6/piff/avc1)
File size : 193 KiB
Duration : 5 s 5 ms
Overall bit rate : 317 kb/s
Frame rate : 23.976 FPS

Video
ID : 1
Format : AVC
Format/Info : Advanced Video Codec
Format profile : High@L2.1
Format settings : CABAC / 5 Ref Frames
Format settings, CABAC : Yes
Format settings, Reference frames : 5 frames
Format settings, Slice count : 7 slices per frame
Codec ID : encv / avc1
Codec ID/Info : Advanced Video Coding
Duration : 5 s 5 ms
Bit rate : 298 kb/s
Width : 512 pixels
Height : 288 pixels
Display aspect ratio : 16:9
Frame rate mode : Constant
Frame rate : 23.976 FPS
Color space : YUV
Chroma subsampling : 4:2:0
Bit depth : 8 bits
Scan type : Progressive
Bits/(Pixel*Frame) : 0.084
Stream size : 182 KiB (94%)
Encryption : Encrypted
Codec configuration box : avcC

Itt az Encryption: Encrypted sor érdekes főként. Ez nálunk nem szerepel. Illetve a Codec ID is Codec ID: encv / avc1.

Ezen a ponton vagy elakadunk, vagy sajnos elő kell venni és meg kell érteni valamennyire az MP4-ek felépítését. Futtatok egy mp4dump parancsot az általam generált init szegmensen:

[steve@todo test]$ mp4dump init.mp4
[ftyp] size=8+16
major_brand = cmfc
minor_version = 0
compatible_brand = dash
compatible_brand = iso6
[moov] size=8+640
[mvhd] size=12+96
timescale = 90000
duration = 0
duration(ms) = 0
[mvex] size=8+32
[trex] size=12+20
track id = 1
default sample description index = 1
default sample duration = 0
default sample size = 0
default sample flags = 0
[trak] size=8+484
[tkhd] size=12+80, flags=7
enabled = 1
id = 1
duration = 0
width = 256.000000
height = 144.000000
[mdia] size=8+384
[mdhd] size=12+20
timescale = 10000000
duration = 0
duration(ms) = 0
language = und
[hdlr] size=12+40
handler_type = vide
handler_name = mp4ff video handler
[minf] size=8+292
[vmhd] size=12+8, flags=1
graphics_mode = 0
op_color = 0000,0000,0000
[dinf] size=8+28
[dref] size=12+16
[url ] size=12+0, flags=1
location = [local to file]
[stbl] size=8+228
[stsd] size=12+148
entry_count = 1
[avc1] size=8+136
data_reference_index = 1
width = 256
height = 144
compressor = mp4ff video packager
[avcC] size=8+50
Configuration Version = 1
Profile = Main
Profile Compatibility = 40
Level = 32
NALU Length Size = 4
Sequence Parameter = [67 4d 40 20 9e 52 82 02 76 02 84 04 04 05 00 00 03 00 01 00 00 03 00 64 e0 00 05 7e 00 0a fc 3f 13 e0 a0]
Picture Parameter = [68 ef 75 20]
[stts] size=12+4
entry_count = 0
[stsc] size=12+4
entry_count = 0
[stsz] size=12+8
sample_size = 0
sample_count = 0
[stco] size=12+4
entry_count = 0

Majd a másik fájlon:

[steve@todo test]$ mp4dump /tmp/mp4ff/cmd/mp4ff-decrypt/testdata/PIFF/video/complseg-1.0001.m
p4
[ftyp] size=8+20
major_brand = dash
minor_version = 0
compatible_brand = iso6
compatible_brand = piff
compatible_brand = avc1
[moov] size=8+1494
[mvhd] size=12+96
timescale = 1000
duration = 0
duration(ms) = 0
[pssh] size=12+688
system_id = [9a 04 f0 79 98 40 42 86 ab 92 e6 5b e0 88 5f 95]
data_size = 668
[pssh] size=12+50
system_id = [ed ef 8b a9 79 d6 4a ce a3 c8 27 dc d5 1d 21 ed]
data_size = 30
[trak] size=8+576
[tkhd] size=12+92, version=1, flags=f
enabled = 1
id = 1
duration = -1
width = 512.000000
height = 288.000000
[mdia] size=8+464
[mdhd] size=12+32, version=1
timescale = 10000000
duration = -1
duration(ms) = 3133608139
language = und
[hdlr] size=12+33
handler_type = vide
handler_name = VideoHandler
[minf] size=8+367
[vmhd] size=12+8, flags=1
graphics_mode = 0
op_color = 0000,0000,0000
[dinf] size=8+28
[dref] size=12+16
[url ] size=12+0, flags=1
location = [local to file]
[stbl] size=8+303
[stsd] size=12+223
entry_count = 1
[encv] size=8+211
data_reference_index = 1
width = 512
height = 288
compressor =
[avcC] size=8+45
Configuration Version = 1
Profile = High
Profile Compatibility = 0
Level = 21
NALU Length Size = 4
Sequence Parameter = [67 64 00 15 ac d9 80 80 25 b0 11 00 00 03 03 e9 00 00 bb 80 0f 16 2d 9a]
Picture Parameter = [68 e9 78 23 2c 8b]
[sinf] size=8+72
[frma] size=8+4
original_format = avc1
[schm] size=12+8
scheme_type = cenc
scheme_version = 65536
[schi] size=8+32
[tenc] size=12+20
default_isProtected = 1
default_Per_Sample_IV_Size = 8
default_KID = [21 b8 2d c2 eb b2 4d 5a a9 f8 63 1f 04 72 66 50]
[stts] size=12+4
entry_count = 0
[stsc] size=12+4
entry_count = 0
[stsz] size=12+8
sample_size = 0
sample_count = 0
[stco] size=12+4
entry_count = 0
[mvex] size=8+32
[trex] size=12+20
track id = 1
default sample description index = 1
default sample duration = 0
default sample size = 0
default sample flags = 0
[sidx] size=12+6416
reference_ID = 1
timescale = 10000000
earliest_presentation_time = 0
first_offset = 0

Elég sok betű, szám meg ijesztő hexa adat. :) Sokat gondolkoztam mindig is arról, hogy hogy a fenébe képes az MP4 implementálni az összes új kodeket, meg formátumot. Hiszen nem gondolhattak mindenre, mikor megalkották a formátumot és útközben is jöttek új kodekek meg stb. Namost, erre egy érdekes megoldás van alkalmazva. Boxokra van osztva a fájl. A cím nem clickbait volt. :) Ezeknek van egy neve, egy mérete, majd egy adat mezője. És ezek lehetnek egymásba láncolva is. Korábban linkeltem ezt. Itt van egy jó ábra egy ilyen boxról:

Nézzük meg újra, amit fentebb láttunk és egyből ki is szúrhatunk egy boxot:

[ftyp] size=8+20
major_brand = dash
minor_version = 0
compatible_brand = iso6
compatible_brand = piff
compatible_brand = avc1

Ennek pl ftyp a neve, ott a mérete, illetve van egy pár adat mező is társítva hozzá. Ami egymásba van ágyazva, az bentebb van húzva.

Az első szembetűnő különbség a két kimenet közt, hogy nincs pssh box a fentiben. A másodiknál viszont van:

[moov] size=8+1494
[mvhd] size=12+96
timescale = 1000
duration = 0
duration(ms) = 0
[pssh] size=12+688
system_id = [9a 04 f0 79 98 40 42 86 ab 92 e6 5b e0 88 5f 95]
data_size = 668
[pssh] size=12+50
system_id = [ed ef 8b a9 79 d6 4a ce a3 c8 27 dc d5 1d 21 ed]
data_size = 30

Az is jól látható, hogy a moov box leszármazottja lesz a PSSH. Nade miért van kettő? Itt a system_id lesz a kulcs. A 9a 04 f0 79 98 40 42 86 ab 92 e6 5b e0 88 5f 95 a PlayReady SystemID-ja. Ez egy olyan UUID, ami fix és statikus. innen lehet felismerni a PlayReady-t. A második pedig egy Widevine SystemID lesz. Esetünkben ilyen nem volt az eredeti manifestben, szóval ezt el lehet hagyni.

Mivel nem szeretném a streaming szolgáltatót megnevezni, a PSSH-t egy Microsoftos teszt fájlból fogom szedni, amit itt találtam: http://profficialsite.origin.mediaservices.windows.net/c51358ea-9a5e-4322-8951-897d640fdfd7/tearsofsteel_4k.ism/manifest

Szóval ebben van egy ilyen sor:

<Protection><ProtectionHeader SystemID="9A04F079-9840-4286-AB92-E65BE0885F95">XAMAAAEAAQBSAzwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZAEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQwBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAD4ANABSAHAAbABiACsAVABiAE4ARQBTADgAdABHAGsATgBGAFcAVABFAEgAQQA9AD0APAAvAEsASQBEAD4APABDAEgARQBDAEsAUwBVAE0APgBLAEwAagAzAFEAegBRAFAALwBOAEEAPQA8AC8AQwBIAEUAQwBLAFMAVQBNAD4APABMAEEAXwBVAFIATAA+AGgAdAB0AHAAcwA6AC8ALwBwAHIAbwBmAGYAaQBjAGkAYQBsAHMAaQB0AGUALgBrAGUAeQBkAGUAbABpAHYAZQByAHkALgBtAGUAZABpAGEAcwBlAHIAdgBpAGMAZQBzAC4AdwBpAG4AZABvAHcAcwAuAG4AZQB0AC8AUABsAGEAeQBSAGUAYQBkAHkALwA8AC8ATABBAF8AVQBSAEwAPgA8AEMAVQBTAFQATwBNAEEAVABUAFIASQBCAFUAVABFAFMAPgA8AEkASQBTAF8ARABSAE0AXwBWAEUAUgBTAEkATwBOAD4AOAAuADEALgAyADMAMAA0AC4AMwAxADwALwBJAEkAUwBfAEQAUgBNAF8AVgBFAFIAUwBJAE8ATgA+ADwALwBDAFUAUwBUAE8ATQBBAFQAVABSAEkAQgBVAFQARQBTAD4APAAvAEQAQQBUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA=</ProtectionHeader></Protection>

Ismerős a SystemID? Pont most beszéltünk róla. Az a hosszú base64 valami meg a PSSH lesz.

Csináljunk tehát egy PSSH boxot:

pssh := "XAMA[...]gA="
psshByte, err := base64.StdEncoding.DecodeString(pssh)
if err != nil {
log.Fatal(err)
}
psshBox := mp4.PsshBox{
SystemID: mp4.UUID{0x9a, 0x04, 0xf0, 0x79, 0x48, 0x5b, 0x48, 0x5a, 0x8b, 0x6a, 0x4f, 0x14, 0x8f, 0x17, 0x9a, 0x17},
Data: psshByte,
}

Eddig ok, de ettől nem lesz hozzáadva a box az init szegmenshez. Ahhoz, ha hozzáadjuk még a init.Moov.AddChild(&psshBox) hívást, akkor megoldja a dolgot:

[steve@todo test]$ go run main.go
2024/02/25 02:48:59 Width: 256, Height: 144
[steve@todo test]$ mp4dump init.mp4 | grep -C5 pssh
[stsz] size=12+8
sample_size = 0
sample_count = 0
[stco] size=12+4
entry_count = 0
[pssh] size=12+880
system_id = [9a 04 f0 79 48 5b 48 5a 8b 6a 4f 14 8f 17 9a 17]
data_size = 860

Jónak tűnik. Első akadály leküzdve. De még mindig van egy különbség. Az encrypted rész a másodikban... Nekünk az stsd box alatt közvetlen egy avc1 box van. A másiknál van viszont egy encv, majd abban szerepel az avcC box. Hogy a fenébe érhetjük ezt el anélkül, hogy kézzel újrarendeznénk az eddig a library által összeállított boxokat? Erre az mp4-encrypt parancs kódja adta meg a választ: [link]. Szerencsére van egy InitProtect hívás, ami egész fejlesztőbarát módon megoldja nekünk a dolgot.

Idézet a dokumentációból:

func mp4.InitProtect(init *mp4.InitSegment, key []byte, iv []byte, scheme string, kid mp4.UUID, psshBoxes []*mp4.PsshBox) (*mp4.InitProtectData, error)
InitProtect modifies the init segment to add protection information and return what is needed to encrypt fragments.

Véletlen nekünk pont ez kell. Szabaduljunk meg a init.Moov.AddChild(&psshBox) sortól, mert azt majd ez a metódus elintézi nekünk. Viszont a keyid-t nem tudjuk még. Szerencsére erre van a DashMe-ben egy nem éppen elegáns, de működőképes kód: [link]

Átemelés után:

keyId := extractKeyId(psshByte)
log.Printf("KeyId: %x", keyId)

[steve@todo test]$ go run main.go
2024/02/25 02:59:08 KeyId: 6f651ae1dbe44434bcb4690d1564c41c

És ahonnan a teszt anyagot szedtem, ott valóban ez volt a keyId, szóval jók vagyunk: [link].

Mehet is az InitProtect hívás:

_, err = mp4.InitProtect(init, nil, nil, "cenc", keyId, []*mp4.PsshBox{&psshBox})
if err != nil {
log.Fatal(err)
}

Mivel a kulcsról és az IV-ről fogalmunk sincs, ezeket nem definiálom. Eleve nem fogunk tudni decryptelni, mert ahhoz DRM-et kellene törni, ami erősen nem legális kategória, így ezeket nil-en hagyom. A séma a szolgáltató esetében CENC. Egyébként ez a gyakoribb megoldás.

Nézzük, hogy jól dolgoztunk-e:

[steve@todo test]$ mp4dump init.mp4
[ftyp] size=8+16
major_brand = cmfc
minor_version = 0
compatible_brand = dash
compatible_brand = iso6
[moov] size=8+1612
[mvhd] size=12+96
timescale = 90000
duration = 0
duration(ms) = 0
[mvex] size=8+32
[trex] size=12+20
track id = 1
default sample description index = 1
default sample duration = 0
default sample size = 0
default sample flags = 0
[trak] size=8+564
[tkhd] size=12+80, flags=7
enabled = 1
id = 1
duration = 0
width = 256.000000
height = 144.000000
[mdia] size=8+464
[mdhd] size=12+20
timescale = 10000000
duration = 0
duration(ms) = 0
language = und
[hdlr] size=12+40
handler_type = vide
handler_name = mp4ff video handler
[minf] size=8+372
[vmhd] size=12+8, flags=1
graphics_mode = 0
op_color = 0000,0000,0000
[dinf] size=8+28
[dref] size=12+16
[url ] size=12+0, flags=1
location = [local to file]
[stbl] size=8+308
[stsd] size=12+228
entry_count = 1
[encv] size=8+216
data_reference_index = 1
width = 256
height = 144
compressor = mp4ff video packager
[avcC] size=8+50
Configuration Version = 1
Profile = Main
Profile Compatibility = 40
Level = 32
NALU Length Size = 4
Sequence Parameter = [67 4d 40 20 9e 52 82 02 76 02 84 04 04 05 00 00 03 00 01 00 00 03 00 64 e0 00 05 7e 00 0a fc 3f 13 e0 a0]
Picture Parameter = [68 ef 75 20]
[sinf] size=8+72
[frma] size=8+4
original_format = avc1
[schm] size=12+8
scheme_type = cenc
scheme_version = 65536
[schi] size=8+32
[tenc] size=12+20
default_isProtected = 1
default_Per_Sample_IV_Size = 16
default_KID = [6f 65 1a e1 db e4 44 34 bc b4 69 0d 15 64 c4 1c]
[stts] size=12+4
entry_count = 0
[stsc] size=12+4
entry_count = 0
[stsz] size=12+8
sample_size = 0
sample_count = 0
[stco] size=12+4
entry_count = 0
[pssh] size=12+880
system_id = [9a 04 f0 79 48 5b 48 5a 8b 6a 4f 14 8f 17 9a 17]
data_size = 860

Rendben megjelent a PSSH box az alján, illetve az stsd box alatt egy encv-ben van az avcC chunk. Szuper! Ezzel kész is van az init szegmensünk. Az eddigi kódot feltöltöttem ide: [link]

Szedjünk le egy szegmenst a fájlból:

[steve@todo test]$ curl 'http://profficialsite.origin.mediaservices.windows.net/c51358ea-9a5e-4322-8951-897d640fdfd7/tearsofsteel_4k.ism/QualityLevels(2181139)/Fragments(video=0)' -o video_0_enc.mp4
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 9817 100 9817 0 0 193k 0 --:--:-- --:--:-- --:--:-- 195k

És nézzük meg, hogy mi történik, ha megpróbáljuk decryptelni. Ugyan nem tudjuk a kulcsot, ami ténylegesen visszafejti, egy random kulccsal rá lehet próbálni. Nem rontunk el vele semmit, csak a kimenet rosszul lesz visszafejtve. Én kulcsnak a keyId-t fogom használni, ami nyilván nem lesz jó kulcs, de nekünk elég tesztelni.

Az mp4ff-decrypt parancsban látszik, hogy hogyan lehet decryptelni: [link]

key := keyId
_, err = mp4.InitProtect(init, key, nil, "cenc", keyId, []*mp4.PsshBox{&psshBox})
if err != nil {
log.Fatal(err)
}

decryptInfo, err := mp4.DecryptInit(init)
if err != nil {
log.Fatal(err)
}
log.Printf("DecryptInfo: %+v", decryptInfo)

Nézzük ennek a kimenetét:

[steve@todo test]$ go run main.go
2024/02/25 03:18:55 KeyId: 6f651ae1dbe44434bcb4690d1564c41c
2024/02/25 03:18:55 Width: 640, Height: 288
2024/02/25 03:18:55 DecryptInfo: {Psshs:[] TrackInfos:[{TrackID:1 Sinf:0xc0000943f0 Trex:0xc00001e3a0 Psshs:[]}]}

Elég fura. Mintha nem ismerné fel a PSSH boxot a dolog, amikor feldolgozza az Init szegmenst... Sokáig szenvedtem rajta, hogy mit ronthattam el, aztán nyitottam egy ticketet Githubon és kiderült, hogy bug volt. Javították: [link]

Hamarosan bekerül a stabil verzióba, addig is:

[steve@todo test]$ go get github.com/Eyevinn/mp4ff/mp4@master
go: upgraded github.com/Eyevinn/mp4ff v0.42.0 => v0.42.1-0.20240222154526-c504a7443037
[steve@todo test]$ go run main.go
2024/02/25 03:21:30 KeyId: 6f651ae1dbe44434bcb4690d1564c41c
2024/02/25 03:21:30 Width: 640, Height: 288
2024/02/25 03:21:30 DecryptInfo: {Psshs:[0xc0000900f0] TrackInfos:[{TrackID:1 Sinf:0xc0000943f0 Trex:0xc00001e3a0 Psshs:[]}]}

Mindjárt jobb!

Nézzük, hogy tudunk-e decryptelni!

inSegment, err := os.ReadFile("video_0_enc.mp4")
if err != nil {
log.Fatal(err)
}
inMP4, err := mp4.DecodeFile(bytes.NewReader(inSegment))
if err != nil {
log.Fatal(err)
}

for _, seg := range inMP4.Segments {
err = mp4.DecryptSegment(seg, decryptInfo, key)
if err != nil {
log.Fatal(err)
}
outFile, err := os.Create("video_0.mp4")
if err != nil {
log.Fatal(err)
}
defer outFile.Close()
err = seg.Encode(outFile)
if err != nil {
log.Fatal(err)
}
}

És igen, előáll egy video.mp4 fájl hiba nélkül. Most ha kiadjuk, hogy:

[steve@todo test]$ cat init.mp4 > test.mp4
[steve@todo test]$ cat video_0.mp4 >> test.mp4

Akkor előáll egy lejátszható MP4 fájl. Mivel viszont a kulcsot nem tudjuk, csak glitcheket fogunk látni. Ha tudtuk volna a kulcsot, amivel eredetileg titkosítva volt, akkor le tudnánk játszani. :)

Viszont ezzel nem lettünk okosabbak. Hiszen a Microsoftos teszt cucc decryptelődik szépen, eddig semmi hiba nem volt.

Feltöltöm most a tényleges szolgáltatóm adataival a teszt fájlt és megnézem, hogy az ő 1080p-s streamjüket tudom-e decryptelni. Annyi csavart rakok a dologba, hogy valamiért náluk a PSSH durván hosszú, mert ki van paddelve feleslegesen. Ezt én levágom:

psshByte = bytes.TrimRight(psshByte, "\x00")

Mert felesleges és rengeteg adatot lehet spórolni vele. Kilobyteokról van szó :D.

Namost, elsőre futtatva ugyanazt a kódot a szolgáltató cuccaival nem fut hibába. De elég fura az eredmény. Ez volt a forrás:

[steve@todo test]$ mp4dump video_0_enc.mp4
[moof] size=8+3517
[mfhd] size=12+4
sequence number = 204840566
[traf] size=8+3493
[tfhd] size=12+8, flags=8
track ID = 7
default sample duration = 200000
[trun] size=12+412, flags=205
sample count = 100
data offset = 3472
first sample flags = 2000000
[sdtp] size=8+104
[6D1D9B0542D5-44E6-80E2-141D-AFF757B2] size=24+20
[A2394F525A9B-4F14-A244-6C42-7C648DF4] size=28+2804, flags=2
sample info count = 100
[D4807EF2CA39-4695-8E54-26CB-9E46A79F] size=24+37
[mdat] size=8+1853296

És ez lett belőle:

[steve@todo test]$ mp4dump video_0.mp4
[moof] size=8+3517
[mfhd] size=12+4
sequence number = 204840566
[traf] size=8+3493
[tfhd] size=12+8, flags=8
track ID = 7
default sample duration = 200000
[trun] size=12+412, flags=205
sample count = 100
data offset = 3533
first sample flags = 2000000
[sdtp] size=8+104
[6D1D9B0542D5-44E6-80E2-141D-AFF757B2] size=24+20
[A2394F525A9B-4F14-A244-6C42-7C648DF4] size=28+2804, flags=3d
AlgorithmID = 7697769
IV_size = 100
KID = [kiszedtem]
sample info count = 16777216

Az a fura UUID nevű box a maga KID-jével meg stb fura... Kicsi debug után megtaláltam, hogy ez a UUID mi lehet: [link] Titkosított szegmenseknél szerepel csak. De most próbáltunk decryptelni... Akkor miért nem strippelte le? Itt le kellett volna: [link].

Na ezen a problémán tényleg többet ültem, mint a kotlós tyúk, de elég triviális utólag a probléma. Szóval a track ID = 7 a szegmensben. Nekünk viszont az init track id 1-et vár decryptionre, ez látszik is a DecryptInfo kimenetben:

2024/02/25 03:42:25 DecryptInfo: {Psshs:[0xc0001520a0] TrackInfos:[{TrackID:1 Sinf:0xc000216120 Trex:0xc000202060 Psshs:[]}]}

így a library nem is próbálja majd meg decryptelni a hetes tracket. Szóval írjuk át a trackid-t szükség esetén:

for _, seg := range inMP4.Segments {
seg.Fragments[0].Moof.Traf.Tfhd.TrackID = 1
seg.Fragments[0].Moof.Mfhd.SequenceNumber = 1
err = mp4.DecryptSegment(seg, decryptInfo, key)
if err != nil {
log.Fatal(err)
}
outFile, err := os.Create("video_0.mp4")
if err != nil {
log.Fatal(err)
}
defer outFile.Close()
err = seg.Encode(outFile)
if err != nil {
log.Fatal(err)
}
}

Fontos, hogy a TrackID a decryption előtt legyen átírva! Ha nem így teszel, mint ahogy én is tettem először, akkor jön a sok órányi fejfájásos debugging, hogy miért maradt bent a kulcs még mindig. Nade, futtassuk:

[steve@todo test]$ go run main.go
2024/02/25 03:47:21 KeyId: KISZEDTEM
2024/02/25 03:47:21 Width: 1920, Height: 1080
2024/02/25 03:47:21 DecryptInfo: {Psshs:[0xc0001800f0] TrackInfos:[{TrackID:1 Sinf:0xc0001923f0 Trex:0xc0001ae1a0 Psshs:[]}]}
2024/02/25 03:47:21 offset in mdata beyond size
exit status 1

Íh, na az oszt micsoda? fatal error? Hát ezt nem az én kódom dobta. Nézzük akkor sorra a történéseket. Szóval a DecryptSegment() hívás meghívja a DecryptFragment()-et, majd ez eljut a GetFullSamples() hívásig és ez a sor dobja a hibát: [link]

Ha rakok egy breakpointot a feltételre és futtatom a kódot, akkor a következőt látom:

Az offsetInMdat túlcsordul azzal, hogy negatívat vonunk belőle és 18446744073709551555 lesz, ami egy picit nagyobb, mint az mdatDataLength, ami 1625420.

Nézzük csak megint a bemeneti szegmens adatát:

[moof] size=8+1867
[mfhd] size=12+4
sequence number = 204854411
[traf] size=8+1843
[tfhd] size=12+8, flags=8
track ID = 8
default sample duration = 400000
[trun] size=12+212, flags=205
sample count = 50
data offset = 1822
first sample flags = 2000000
[sdtp] size=8+54
[6D1D9B0542D5-44E6-80E2-141D-AFF757B2] size=24+20
[A2394F525A9B-4F14-A244-6C42-7C648DF4] size=28+1404, flags=2
sample info count = 50
[D4807EF2CA39-4695-8E54-26CB-9E46A79F] size=24+37
[mdat] size=8+1625420

Illetve olvassuk hozzá a kódot párhuzamosan:

for _, trun := range traf.Truns {
totalDur := trun.AddSampleDefaultValues(tfhd, trex)
// The default is moofStartPos according to Section 8.8.7.1
baseOffset := moofStartPos
if tfhd.HasBaseDataOffset() {
baseOffset = tfhd.BaseDataOffset
} else if tfhd.DefaultBaseIfMoof() {
baseOffset = moofStartPos
}
if trun.HasDataOffset() {
baseOffset = uint64(int64(trun.DataOffset) + int64(baseOffset))
}
mdatDataLength := uint64(len(mdat.Data)) // len should be fine for 64-bit
var offsetInMdat uint64
if baseOffset > 0 {
offsetInMdat = baseOffset - mdat.PayloadAbsoluteOffset()
if offsetInMdat > mdatDataLength {
return nil, fmt.Errorf("offset in mdata beyond size")
}
} else {
offsetInMdat = 0
}

A trun.HasDataOffset() lesz az első ág, amibe belefut esetünkben. Innen a trun.DataOffset, ahogy látszik az mp4dumpból is 1822. A baseOffset eddig 0 volt, szóval most 1822 lesz. Az mdat.Data is leolvasható, 1625420.

Na, hát ez így tényleg nem lesz jó, hiszen a offsetInMdat = baseOffset - mdat.PayloadAbsoluteOffset() 1883 - 1625420 lesz, ami uint64-ben túlcsordul:

És ott az a rejtélyes szám korábbról. :)

Ezen a ponton érdekelt, hogy ez hogy lehetséges. Megpróbáltam azt, hogy kiszedtem a decryption próbálkozást a Gó kódomból, hogy csak a trackId-t írja át 1-re, majd összefűztem az initet a kimenet szegmenssel és megpróbáltam Bento4-gyel visszafejteni:

[steve@todo test]$ cat init.mp4 > test.mp4
[steve@todo test]$ cat video_0.mp4 >> test.mp4
[steve@todo test]$ mp4decrypt --key 1:6f651ae1dbe44434bcb4690d1564c41c test.mp4 out.mp4
ERROR: failed to process the file (-10)

Azért itt éreztem, hogy gond van. Elvileg a -10, innen a AP4_ERROR_INVALID_FORMAT-et jelenti. A Kodi pedig talán Bento4-et használna magától is, szóval igen, ez megmagyarázhatja, hogy miért nem ment a playback az elején.

De azért megkerestem ezt a doksit, amit a Go kód is említ: [link] Meg rengeteget kutattam, hogy ez most a szolgáltató sara, vagy a library implementációja rossz. Arra a következtetésre jutottam, hogy a szolgáltató rontotta el a dolgot. Azt nem tudom, hogy szándékosan-e, hogy csökkentse a kalózok számát, de emiatt nem ment nekem a lejátszás...

Tüneti kezelésnek vagy azt tudom csinálni, hogy a library kódját írom át, hogy return nil, fmt.Errorf("offset in mdata beyond size") helyett dobjon egy warningot és állítsa az offsetInMdatot nullára, vagy simán a kódomban kinullázom ezt az értéket: seg.Fragments[0].Moof.Traf.Trun.DataOffset = 0.

Az utóbbi szimpatikusabb, mert reprodukálhatóbb és nem kell libraryt szerkeszteni hozzá.

Most, hogy megoldódott a rejtély, írtam egy webszervert, amire ha manifest kérés érkezik, akkor letölti a távoli szerverről a smooth streaming manifestet, generál init szegmenseket minden hang és videósávhoz, majd átfordítja a manifestet DASH formátumba és visszaküldi. Utána pedig a szegmenseket a fentebb ecsetelt módon újracsomagolja. Ez azt jelenti, hogy a DataOffset = 0-et beállítja, illetve a TrackId-t és fontos, hogy itt már nem próbálok meg random kulccsal decryptelni. Azt majd megteszi a TV.

Ami hátra van még, az a HLS támogatás, de az fmp4 támogatás ott egész újkeletű, meg az, hogy külön van a hang és a videó, szóval ezzel kapcsolatban még kutatnom kell. Egyelőre a videó sávot sikerült továbbítani, a hangot még nem. Meg a Kodi-s ISA nem támogatja HLS-sel a PlayReady-t tudtommal, így ez nem is nagyon priorítás. A DASH viszont szépen megy. :)

És hát az eredmény magáért beszél! Végre használhatom azt, amire előfizettem! És nincs szükség transzkódolásra, szóval egy Pi0 is röhögve elboldogul a művelettel. Rá van kötve a TV-re USB-n, így azzal bootol és várja a beérkező stream kérelmeket. A TV pedig nem gebed bele a decryptionbe, mint ahogy azt az Androidos app tenné, mert itt hardveresen van megoldva az encoding. És mivel direktben a TV-n fut, szebb képe nem is lehetne. Késleltetés is szinte alig van, egy chunkot kiszolgálni 2-3 ms-be telik a Pi-n.

A végszó pedig. Az biztos, hogy rengeteget tanultam a formátumról és hát a fene gondolta volna, hogy ilyen bonyolult dolgokat rejtenek el a szemem elől a streamingek nap, mint nap, vagy akár bármikor, ha valamilyen mémet nézek. Jó volt ez a gyors betekintés azért is, hogy jobban megértsem ezeknek a működését és láttam azt, hogy milyen összetett feladat lehet egy-egy ilyen streaming platformot fenntartani.

A cikket egyébként ez a remek cikk inspirálta. Ahol a szerző Asahi Linuxra varázsolt widevine támogatást. :)

Köszönet, hogy elolvastad!

Hozzászólások

(#1) Mr Dini


Mr Dini
addikt
LOGOUT blog

No, hát ez minden önfényezés nélkül egy kemény menet volt. Őszintén csodálom, hogy maradt még hajam a végére... És akkor arról nem is írtam, hogy a CENC titkosítás hogyan működik pontosan, mert féltem, hogy még azt az egy olvasót is elveszteném, aki a száraz írás végére ér.

De kifejezetten elégedett vagyok a végeredménnyel. Csak tényleg azt nem tudom, hogy a szolgáltató vajon tényleg szándékos döntések sorozataként építette fel ilyen standardtól eltérő módon a streamingjét, vagy a smooth streaming csak valami legacy döntés és a rossz DataOffset is csak valami ffmpeg-től érkező örökség eredménye.

Egyébként joggal merülhet fel a kérdés, hogy akkor az ffmpeg nem tud smooth streaminget értelmezni? Demuxolni nem tud, csak muxolni. Szóval MSS-t lehet vele csinálni, de fordítva nem megy, hogy majd ő játssza le/enkódolja.

Hogy hívják az éhes horgászt? Gyere Pista, kész a kaja!

(#2) Hieronymus válasza Mr Dini (#1) üzenetére


Hieronymus
addikt
LOGOUT blog

Igen. Ez a bonyolultabb út. Az átlag ember vesz egy androidos tévé okosítót a régi tévéjéhez.
Olcsóbb és tartósabb a termék támogatás. Viszont nem emeli az ismeretanyagot.

Legyen béke! Menjenek az orosz katonák haza, azonnal!

(#3) Mr Dini válasza Hieronymus (#2) üzenetére


Mr Dini
addikt
LOGOUT blog

No igen, csak itt pont hogy Android TV-m volt (2022-ben vettem és nem volt olcsó) eleve és arra nem volt szolgáltatói app. Csak LG és Samsung TV-ken megy a dolog, meg Xboxon és PS-en és Androidon. TV okosítókon direkt nem fog menni, mert valamiért szűrik a hivatalos alkalmazásban azt is. :)

Ha ment volna Android TV-n a gyári app valahogy, nem szenvedtem volna végig mindezt...

[ Szerkesztve ]

Hogy hívják az éhes horgászt? Gyere Pista, kész a kaja!

(#4) hcl


hcl
félisten
LOGOUT blog

Aztamocskos :Y Szép munka!

Mutogatni való hater díszpinty

(#5) hcl


hcl
félisten
LOGOUT blog

BTW végigolvastam, leesett, miért box :P
Azért ez keményen sok meló volt :Y :R

Mutogatni való hater díszpinty

További hozzászólások megtekintése...
Copyright © 2000-2024 PROHARDVER Informatikai Kft.