Fehlerhaft kopierte MP4-Datei reparieren

Neulich kam ein Kumpel und Arbeitskollege mit einem ''kleinen Problem'' zu mir. Er hatte mit seinem Android-Smartphone (Galaxy S3) mehrere Videos gemacht, und eins davon wurde nicht mehr abgespielt, nachdem er die Datei auf der MicroSD-Karte in einen anderen Ordner verschoben hatte. Na gut, dachte ich, da gibt's doch sicher ein kleines Tool für, mit dem sich das schnell wieder reparieren lässt. Tja, Pustekuchen, war wohl nix...
Als erstes habe ich natürlich versucht, die Datei in sämtlichen Playern und Konvertern zu öffnen, die mir so bekannt sind. Keiner davon wollte aber auch nur annähernd irgendwas mit dem Clip anfangen. Also habe ich nach einer Art Repair-Tool für MP4-Dateien gesucht. So wirklich finden konnte ich da nichts, außer ein HD Video Repair Utility. Praktischerweise gab's das direkt als Testversion zum Download, welche immerhin 50% des Videos wiederherstellen kann. Also ausprobiert und... Naja. Ein Video kam dabei raus, aber der Ton war nur die ersten paar Sekunden lang synchron, danach fehlten immer mehr Audio-Samples. Kurzum: In meinem Fall nicht zu gebrauchen.

Also blieb mir nur noch der HexEditor als letztes Werkzeug. Dazu habe ich mir erstmal ein paar Informationen zum MP4-Dateiformat besorgt. Als sehr hilfreich erwiesen sich die folgenden Seiten (sowie ein paar Forenbeiträge):

http://thompsonng.blogspot.de/2010/11/mp4-file-format.html
http://thompsonng.blogspot.de/2010/11/mp4-file-format-part-2.html
http://code.google.com/p/mp4parser/

Kurz zusammengefasst: Eine MP4-Datei besteht aus sogenannten Atoms. Ein Atom enthält, im einfachsten Falle, einen festen Header (bestehend aus der Längenangabe des Atoms und einem Kennzeichen aus 4 Kleinbuchstaben) und dann die entsprechenden Daten. Das Grundgerüst sieht normalerweise so aus:

ftyp
mdat
moov

Wobei ftyp die generellen Infos zu den verwendeten Codecs enthält, mdat die eigentlichen Video-Daten und moov eine Art Tabelle mit den genauen Eckdaten zu jedem Sample (Länge, Position in der Datei, Codierverfahren, etc.). Wenn jetzt eine MP4-Datei unvollständig ist und am Ende die moov-Daten nicht oder nur teilweise vorhanden sind, weiss der Player zum Beispiel nichts mehr mit den mdat-Daten anzufangen, weil er nicht weiss wo Anfang und wo Ende ist. Das kann zum Beispiel der Fall sein, wenn eine Aufnahme abgebrochen wurde, weil plötzlich der Akku leer war oder so. War hier jetzt nicht der Fall, ein kurzer Blick im HexEditor reichte um zu sehen, dass der moov-Teil vorhanden war und auch ähnlich aussah wie bei einem funktionierenden Clip.

Da die genaue Abfolge der Atoms in einer MP4-Datei nicht zu 100% exakt vorgegeben ist, gibt es auch noch folgende Möglichkeit:

ftyp
moov
mdat

Da im mdat-Atom ja feste Offsets enthalten sind, kann man die Atoms natürlich nicht so ohne weiteres vertauschen, aber genau das habe ich als nächstes gemacht: Das moov-Atom mit dem HexEditor direkt zwischen ftyp und moov gesetzt. Dadurch konnte ich die Datei zwar immer noch nicht abspielen, aber zumindest schonmal mit dem MP4-Parser öffnen. Die angezeigten Daten waren alle vollständig, also konnte der Fehler nur noch im mdat-Atom liegen. Na toll, dachte ich. Hier mal ein kleines Beispiel, wie die mdat-Daten im HexEditor aussehen:

Die Markierung zeigt den Anfang eines Samples

Und das über fast 2GB, wo soll man da einen Fehler finden?! Also musste ich wohl oder übel wie ein Decoder an die Sache rangehen und die mdat-Daten mithilfe der moov-Daten entschlüsseln.
Dazu nochmal eine kleine Info: Das mdat-Atom besteht in erster Linie aus sogenannten Chunks. Dabei gibt es Chunks für jeden Track in der Datei, in meinem Falle also zwei (einmal Video, einmal Audio). Diese Chunks sind dann nochmals in Samples unterteilt, wobei die Anzahl der Samples in jedem Chunk variabel ist. Mein zu rettender Clip ist also ca. 1.8GB groß, hat knapp 15 Minuten Laufzeit bei einer Auflösung von 1920x1080 Pixeln und enthält ganze 866 Chunks für die Videospur. Jeder Chunk ist so bummelig 2MB groß - Da kann man im HexEditor immer noch nichts mit anfangen. 2MB sind immer noch eine Menge Gemüse zum durchsuchen, der Screenshot oben zeigt mal gerade 0,7kB... Jeder Chunk besteht ja aber nun aus mehreren Samples, in meinem Falle meistens aus 31. Damit verbleiben immer noch knapp 70kB pro Sample, und das ganze dann über 26.000 Mal! Viel zu viel um das ''mal eben von Hand'' im HexEditor durchzugehen.

Also habe ich mein gutes altes Visual Basic zur Hilfe genommen. Scheisse ja, das ist sicher nicht so elegant wie ein Python- oder Java-Script, aber damit bin ich eben halt vertraut und kann gleich loslegen und erreichen was ich will...
Ich habe also einen kleinen Parser geschrieben, der das moov-Atom einliest, alle relevanten Sample-Daten rauszieht und mit deren Hilfe die jeweils ersten 16 Bytes von jedem Sample ausliest und in eine Log-Datei schreibt. Klingt easy, hat auch nur einen ganzen Abend gedauert bis ich das Ganze soweit am Laufen hatte... ;) Der MP4-Parser war dabei übrigens überaus hilfreich.
Und so sieht das dann im Logfile aus:

13:02:31   chunk:001  sample:001  length:068464  offset:000000032  data:00 01 0B 6C 65 B8 40 7F 8D D8 25 E8 06 A8 73 AF
13:02:31   chunk:001  sample:002  length:043233  offset:000068496  data:00 00 A8 DD 41 E2 21 19 C7 80 F5 B4 02 1B ED CA
13:02:31   chunk:001  sample:003  length:061378  offset:000111729  data:00 00 EF BE 41 E4 41 19 AF 27 B2 A4 20 3E D3 85
13:02:31   chunk:001  sample:004  length:067667  offset:000173107  data:00 01 08 4F 41 E6 61 15 BB 15 08 CC FD 6E 4D 9C
13:02:31   chunk:001  sample:005  length:066737  offset:000240774  data:00 01 04 AD 41 E8 81 15 BE D4 30 89 3D 22 5D 89
13:02:31   chunk:001  sample:006  length:065903  offset:000307511  data:00 01 01 6B 41 EA A1 15 EF D0 DF 29 10 FE DA C0
13:02:31   chunk:001  sample:007  length:066538  offset:000373414  data:00 01 03 E6 41 EC C1 15 E7 76 EE BB 33 6A 4C 3F
13:02:31   chunk:001  sample:008  length:068770  offset:000439952  data:00 01 0C 9E 41 EE E1 15 BE FD 53 E8 36 EA 14 2E
13:02:31   chunk:001  sample:009  length:067051  offset:000508722  data:00 01 05 E7 41 F1 01 15 BB AB 34 12 CD 31 1E 19
13:02:31   chunk:001  sample:010  length:065392  offset:000575773  data:00 00 FF 6C 41 F3 21 15 BE 3B CB 9F 80 4E 05 E0
13:02:31   chunk:001  sample:011  length:066534  offset:000641165  data:00 01 03 E2 41 F5 41 15 9F 58 43 8D E9 6D 76 02
13:02:31   chunk:001  sample:012  length:067386  offset:000707699  data:00 01 07 36 41 F7 61 15 BC E6 CE 98 D5 17 80 E6
13:02:31   chunk:001  sample:013  length:069420  offset:000775085  data:00 01 0F 28 41 F9 81 15 F2 40 D1 14 20 66 A9 D1
13:02:31   chunk:001  sample:014  length:067099  offset:000844505  data:00 01 06 17 41 FB A1 15 BE 3C F7 19 E9 1B DF D3
13:02:31   chunk:001  sample:015  length:067050  offset:000911604  data:00 01 05 E6 41 FD C1 15 C4 80 B2 80 0A 12 66 51
13:02:31   chunk:001  sample:016  length:065352  offset:000978654  data:00 00 FF 44 41 FF E1 15 9E C4 FD 18 35 EC B2 03
13:02:31   chunk:001  sample:017  length:067313  offset:001044006  data:00 01 06 ED 41 E0 01 15 BE 3B 92 63 87 6D D6 6F
13:02:31   chunk:001  sample:018  length:068839  offset:001111319  data:00 01 0C E3 41 E2 21 15 F6 00 2A E1 B6 8F 71 72
13:02:31   chunk:001  sample:019  length:067015  offset:001180158  data:00 01 05 C3 41 E4 41 15 C3 E6 E2 FD 88 8C B8 57
13:02:31   chunk:001  sample:020  length:067102  offset:001247173  data:00 01 06 1A 41 E6 61 15 BD DE 9E 3A 6A 5E BA BF

Natürlich kann man mit so ein paar Sample-Daten alleine nix anfangen, aber es ist doch schonmal ein Muster zu erkennen: Fast alle Samples fangen mit ''00 01'' an und das 5. Byte ist meistens ''41''. Und genau so habe ich dann den ersten Fehler gefunden. Seht her:

13:03:22   chunk:194  sample:5990  length:066827  offset:427429546  data:00 01 05 07 41 E6 61 37 CB FE CD 22 5F 17 AF 2C
13:03:22   chunk:194  sample:5991  length:067683  offset:427496373  data:00 01 08 5F 41 E8 81 37 DA BF 3B FC F2 FA 98 B8
13:03:22   chunk:194  sample:5992  length:067567  offset:427564056  data:00 01 07 EB 41 EA A1 37 DB 4E 80 2C 3A F2 C2 2A
13:03:22   chunk:194  sample:5993  length:067774  offset:427631623  data:00 01 08 BA 41 EC C1 37 DB 39 CE 20 C0 27 8F 60
13:03:22   chunk:194  sample:5994  length:066047  offset:427699397  data:00 01 01 FB 41 EE E1 37 DA E5 F9 C3 CB D1 3B 7D
13:03:22   chunk:194  sample:5995  length:067542  offset:427765444  data:00 01 07 D2 41 F1 01 37 50 11 5D 4E C1 34 F5 61
13:03:22   chunk:194  sample:5996  length:066882  offset:427832986  data:00 01 05 3E 41 F3 21 37 DA 4C CC D1 E2 65 17 3E
13:03:22   chunk:194  sample:5997  length:066948  offset:427899868  data:00 01 05 80 41 F5 41 37 F2 86 A7 7A 36 85 D5 A4
13:03:22   chunk:194  sample:5998  length:068321  offset:427966816  data:00 01 0A DD 41 F7 61 37 DA 46 5F C0 38 AA F4 76
13:03:22   chunk:194  sample:5999  length:067126  offset:428035137  data:00 01 06 32 41 F9 81 37 7D 0E 62 28 8B 4D 21 2A
13:03:22   chunk:194  sample:6000  length:066964  offset:428102263  data:8D 3F 61 F7 A8 4E 11 D4 F5 50 F0 8D AD FB 37 16
13:03:22   chunk:194  sample:6001  length:171903  offset:428169227  data:EC C8 64 77 FA CE 75 4A B2 87 DA FC F9 51 44 09
13:03:22   chunk:194  sample:6002  length:074227  offset:428341130  data:ED D6 44 D5 5E BA DA E0 A9 82 CA D9 06 1B D7 00
13:03:22   chunk:194  sample:6003  length:067298  offset:428415357  data:FE C4 4C 18 FF EC B5 AB C0 50 B4 A6 7C 2A AE 3E
13:03:22   chunk:194  sample:6004  length:070040  offset:428482655  data:BA 25 55 4B DC EF 5F 03 BA 10 56 9E DD 68 63 8B
13:03:22   chunk:194  sample:6005  length:065621  offset:428552695  data:C2 E3 18 05 D8 37 02 AD B3 AA 5A 9D 9E 55 4D 4B
13:03:22   chunk:194  sample:6006  length:065906  offset:428618316  data:7F DB E8 38 65 77 19 02 5F 12 9E 08 2B 01 39 B3
13:03:22   chunk:194  sample:6007  length:069671  offset:428684222  data:65 9E 44 80 4F F5 AB 3C 4C E7 6F D5 93 B6 74 97
13:03:22   chunk:194  sample:6008  length:066243  offset:428753893  data:F6 67 24 E9 36 E1 6D 3D DE 61 81 EA B7 58 FA CE
13:03:22   chunk:194  sample:6009  length:067957  offset:428820136  data:24 74 89 25 15 9D 53 26 1C 98 94 79 9A FF CB 69
13:03:22   chunk:194  sample:6010  length:065729  offset:428888093  data:4F 56 AE 55 31 56 2E EA 01 29 56 10 B7 85 29 A2

Sample 5998 ist wohl noch in Ordnung, weil der Anfang von 5999 auch noch gut aussieht. Aber dann Sample 6000 passt schon gar nicht mehr, also muss irgendwo bei 5999 was schief gelaufen sein. Da ich ja jetzt zu jedem Sample den Offset hatte, konnte ich das 5999ste Sample im HexEditor markieren und nach so etwas wie ''00 01 xx xx 41'' Ausschau halten, um den Anfang des nächsten Samples zu finden. Am einfachsten war eine Suche nach ''00 01'', die auch prompt lediglich ein einziges Ergebnis brachte. Von diesem Offset habe ich dann den Startoffset des Samples abgezogen und bekam folgendes Ergebnis: 4096. Also ist offenbar beim Verschieben der Datei ein ganzer Cluster von 4kB verloren gegangen. Scheint mir eine plausible Erklärung zu sein!
Die Daten sind natürlich verloren, aber bei einem einzelnen Sample verursacht das lediglich einen kleinen Ratzer. Ich habe einfach die vorangegangenen 4kB kopiert und an der Stelle wieder eingefügt, damit die nachfolgenden Offsets wieder stimmen. Wahrscheinlich hätte ich auch mit Nullen auffüllen können, wollte ich aber nicht ;).

Leider wollte das Video danach aber immer noch nicht spielen, also habe ich noch mal mein VB-Tool drüberlaufen lassen. Der 194. Chunk war jedenfalls wieder in Ordnung und auch alle weiteren Chunks bis zu Nummer 416 - das 12.897ste Sample war wieder fehlerhaft. Also den ganzen Vorgang wiederholt, wieder war die Suche nach ''00 01'' erfolgreich, und diesmal hat's dann wirklich geklappt! Das Video läuft wieder!
Glücklicherweise lagen die beiden Fehler mitten in einem Video-Chunk, sonst hätte ich den Audio-Track auch noch durchgehen müssen...

Die Arbeitsweise des Programms ist in etwa so:

Das Ganze ist jetzt natürlich ''nur'' auf meinen speziellen Fall zugeschnitten. Das MP4-Format ist ja noch wesentlich umfangreicher, aber ein Programm für jeden erdenklichen Fall zu schreiben ist sicherlich ein riesen Aufwand. Jedenfalls hat's geklappt, und vielleicht ist es ja eine Anregung für den Einen oder Anderen von euch in einem solchen Fall nicht gleich die Flinte ins Korn zu werfen.
Auf meinen Quick'n'Dirty-Code kann ich zwar nicht wirklich stolz sein, aber ich will ihn euch für eigene Experimente auch nicht vorenthalten, also gibt's hier den ganzen Mist zum runterladen ;).