mirror of
https://github.com/wader/fq.git
synced 2024-11-30 09:58:13 +03:00
0d0147643a
Add cmp package from go 1.21 to have cmp.Compare to make sort easier
466 lines
13 KiB
Go
466 lines
13 KiB
Go
package mp4
|
|
|
|
// Tries to decode ISOBMFF quicktime mov
|
|
// Uses naming from ISOBMFF when possible
|
|
// ISO/IEC 14496-12
|
|
// Quicktime file format https://developer.apple.com/standards/qtff-2001.pdf
|
|
// FLAC in ISOBMFF https://github.com/xiph/flac/blob/master/doc/isoflac.txt
|
|
// vp9 in ISOBMFF https://www.webmproject.org/vp9/mp4/
|
|
// https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW43
|
|
|
|
// TODO: validate structure better? trak/stco etc
|
|
// TODO: keep track of structure somehow to detect errors
|
|
// TODO: ISO-14496 says mp4 mdat can begin and end with original header/trailer (no used?)
|
|
// TODO: split into mov and mp4 decoder?
|
|
// TODO: split into mp4_box decoder? needs complex in/out args?
|
|
// TODO: better probe, find first 2 boxes, should be free,ftyp or mdat?
|
|
|
|
import (
|
|
"embed"
|
|
"fmt"
|
|
|
|
"github.com/wader/fq/format"
|
|
"github.com/wader/fq/internal/cmpex"
|
|
"github.com/wader/fq/pkg/decode"
|
|
"github.com/wader/fq/pkg/interp"
|
|
"golang.org/x/exp/slices"
|
|
)
|
|
|
|
//go:embed mp4.jq
|
|
//go:embed mp4.md
|
|
var mp4FS embed.FS
|
|
|
|
var aacFrameGroup decode.Group
|
|
var av1CCRGroup decode.Group
|
|
var av1FrameGroup decode.Group
|
|
var avcAUGroup decode.Group
|
|
var avcDCRGroup decode.Group
|
|
var flacFrameGroup decode.Group
|
|
var flacMetadatablocksGroup decode.Group
|
|
var hevcAUGroup decode.Group
|
|
var hevcCDCRGroup decode.Group
|
|
var iccProfileGroup decode.Group
|
|
var id3v2Group decode.Group
|
|
var imageGroup decode.Group
|
|
var jpegGroup decode.Group
|
|
var mp3FrameGroup decode.Group
|
|
var mpegESGroup decode.Group
|
|
var mpegPESPacketSampleGroup decode.Group
|
|
var opusPacketFrameGroup decode.Group
|
|
var pngGroup decode.Group
|
|
var proResFrameGroup decode.Group
|
|
var protoBufWidevineGroup decode.Group
|
|
var psshPlayreadyGroup decode.Group
|
|
var vorbisPacketGroup decode.Group
|
|
var vp9FrameGroup decode.Group
|
|
var vpxCCRGroup decode.Group
|
|
|
|
func init() {
|
|
interp.RegisterFormat(
|
|
format.MP4,
|
|
&decode.Format{
|
|
Description: "ISOBMFF, QuickTime and similar",
|
|
Groups: []*decode.Group{
|
|
format.Probe,
|
|
format.Image, // avif
|
|
},
|
|
DecodeFn: mp4Decode,
|
|
DefaultInArg: format.MP4_In{
|
|
DecodeSamples: true,
|
|
AllowTruncated: false,
|
|
},
|
|
Dependencies: []decode.Dependency{
|
|
{Groups: []*decode.Group{format.AAC_Frame}, Out: &aacFrameGroup},
|
|
{Groups: []*decode.Group{format.AV1_CCR}, Out: &av1CCRGroup},
|
|
{Groups: []*decode.Group{format.AV1_Frame}, Out: &av1FrameGroup},
|
|
{Groups: []*decode.Group{format.AVC_AU}, Out: &avcAUGroup},
|
|
{Groups: []*decode.Group{format.AVC_DCR}, Out: &avcDCRGroup},
|
|
{Groups: []*decode.Group{format.FLAC_Frame}, Out: &flacFrameGroup},
|
|
{Groups: []*decode.Group{format.FLAC_Metadatablocks}, Out: &flacMetadatablocksGroup},
|
|
{Groups: []*decode.Group{format.HEVC_AU}, Out: &hevcAUGroup},
|
|
{Groups: []*decode.Group{format.HEVC_DCR}, Out: &hevcCDCRGroup},
|
|
{Groups: []*decode.Group{format.ICC_Profile}, Out: &iccProfileGroup},
|
|
{Groups: []*decode.Group{format.ID3v2}, Out: &id3v2Group},
|
|
{Groups: []*decode.Group{format.Image}, Out: &imageGroup},
|
|
{Groups: []*decode.Group{format.JPEG}, Out: &jpegGroup},
|
|
{Groups: []*decode.Group{format.MP3_Frame}, Out: &mp3FrameGroup},
|
|
{Groups: []*decode.Group{format.MPEG_ES}, Out: &mpegESGroup},
|
|
{Groups: []*decode.Group{format.MPEG_PES_Packet}, Out: &mpegPESPacketSampleGroup},
|
|
{Groups: []*decode.Group{format.Opus_Packet}, Out: &opusPacketFrameGroup},
|
|
{Groups: []*decode.Group{format.PNG}, Out: &pngGroup},
|
|
{Groups: []*decode.Group{format.Prores_Frame}, Out: &proResFrameGroup},
|
|
{Groups: []*decode.Group{format.ProtobufWidevine}, Out: &protoBufWidevineGroup},
|
|
{Groups: []*decode.Group{format.PSSH_Playready}, Out: &psshPlayreadyGroup},
|
|
{Groups: []*decode.Group{format.Vorbis_Packet}, Out: &vorbisPacketGroup},
|
|
{Groups: []*decode.Group{format.VP9_Frame}, Out: &vp9FrameGroup},
|
|
{Groups: []*decode.Group{format.VPX_CCR}, Out: &vpxCCRGroup},
|
|
},
|
|
})
|
|
interp.RegisterFS(mp4FS)
|
|
}
|
|
|
|
type stsc struct {
|
|
firstChunk int
|
|
samplesPerChunk int
|
|
}
|
|
|
|
type moof struct {
|
|
offset int64
|
|
defaultSampleSize int64
|
|
defaultSampleDescriptionIndex int
|
|
truns []trun
|
|
sencs []senc
|
|
}
|
|
|
|
// TODO: nothing for now
|
|
type senc struct {
|
|
entries []struct{}
|
|
}
|
|
|
|
type trun struct {
|
|
dataOffset int64
|
|
samplesSizes []int64
|
|
}
|
|
|
|
type sampleDescription struct {
|
|
dataFormat string
|
|
originalFormat string
|
|
}
|
|
|
|
type stsz struct {
|
|
size int64
|
|
count int
|
|
}
|
|
|
|
type track struct {
|
|
seenHdlr bool
|
|
id int
|
|
sampleDescriptions []sampleDescription
|
|
subType string
|
|
stco []int64
|
|
stsc []stsc
|
|
stsz []stsz
|
|
formatInArg any
|
|
objectType int // if data format is "mp4a"
|
|
defaultIVSize int
|
|
moofs []*moof // for fmp4
|
|
}
|
|
|
|
type pathEntry struct {
|
|
typ string
|
|
data any
|
|
}
|
|
|
|
type decodeContext struct {
|
|
opts format.MP4_In
|
|
path []pathEntry
|
|
tracks map[int]*track
|
|
}
|
|
|
|
func (ctx *decodeContext) lookupTrack(id int) *track {
|
|
t, ok := ctx.tracks[id]
|
|
if !ok {
|
|
t = &track{id: id}
|
|
ctx.tracks[id] = t
|
|
}
|
|
return t
|
|
}
|
|
|
|
func (ctx *decodeContext) isParent(typ string) bool {
|
|
return ctx.parent().typ == typ
|
|
}
|
|
|
|
func (ctx *decodeContext) parent() pathEntry {
|
|
return ctx.path[len(ctx.path)-2]
|
|
}
|
|
|
|
func (ctx *decodeContext) findParent(typ string) any {
|
|
for i := len(ctx.path) - 1; i >= 0; i-- {
|
|
p := ctx.path[i]
|
|
if p.typ == typ {
|
|
return p.data
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ctx *decodeContext) rootBox() *rootBox {
|
|
t, _ := ctx.findParent("").(*rootBox)
|
|
return t
|
|
}
|
|
|
|
func (ctx *decodeContext) currentTrakBox() *trakBox {
|
|
t, _ := ctx.findParent("trak").(*trakBox)
|
|
return t
|
|
}
|
|
|
|
func (ctx *decodeContext) currentTrafBox() *trafBox {
|
|
t, _ := ctx.findParent("traf").(*trafBox)
|
|
return t
|
|
}
|
|
|
|
func (ctx *decodeContext) currentMoofBox() *moofBox {
|
|
t, _ := ctx.findParent("moof").(*moofBox)
|
|
return t
|
|
}
|
|
|
|
func (ctx *decodeContext) currentMetaBox() *metaBox {
|
|
t, _ := ctx.findParent("meta").(*metaBox)
|
|
return t
|
|
}
|
|
|
|
func (ctx *decodeContext) currentTrack() *track {
|
|
if t := ctx.currentTrakBox(); t != nil {
|
|
return ctx.lookupTrack(t.trackID)
|
|
}
|
|
if t := ctx.currentTrafBox(); t != nil {
|
|
return ctx.lookupTrack(t.trackID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mp4Tracks(d *decode.D, ctx *decodeContext) {
|
|
// keep track order stable
|
|
var sortedTracks []*track
|
|
for _, t := range ctx.tracks {
|
|
sortedTracks = append(sortedTracks, t)
|
|
}
|
|
slices.SortFunc(sortedTracks, func(a, b *track) int { return cmpex.Compare(a.id, b.id) })
|
|
|
|
d.FieldArray("tracks", func(d *decode.D) {
|
|
for _, t := range sortedTracks {
|
|
decodeSampleRange := func(d *decode.D, t *track, decodeSample bool, dataFormat string, name string, firstBit int64, nBits int64, inArg any) {
|
|
d.RangeFn(firstBit, nBits, func(d *decode.D) {
|
|
if !decodeSample {
|
|
d.FieldRawLen(name, d.BitsLeft())
|
|
return
|
|
}
|
|
|
|
switch {
|
|
case dataFormat == "fLaC":
|
|
d.FieldFormatLen(name, nBits, &flacFrameGroup, inArg)
|
|
case dataFormat == "Opus":
|
|
d.FieldFormatLen(name, nBits, &opusPacketFrameGroup, inArg)
|
|
case dataFormat == "vp09":
|
|
d.FieldFormatLen(name, nBits, &vp9FrameGroup, inArg)
|
|
case dataFormat == "avc1":
|
|
d.FieldFormatLen(name, nBits, &avcAUGroup, inArg)
|
|
case dataFormat == "hev1",
|
|
dataFormat == "hvc1":
|
|
d.FieldFormatLen(name, nBits, &hevcAUGroup, inArg)
|
|
case dataFormat == "av01":
|
|
d.FieldFormatLen(name, nBits, &av1FrameGroup, inArg)
|
|
case dataFormat == "mp4a" && t.objectType == format.MPEGObjectTypeMP3:
|
|
d.FieldFormatLen(name, nBits, &mp3FrameGroup, inArg)
|
|
case dataFormat == "mp4a" && t.objectType == format.MPEGObjectTypeAAC:
|
|
d.FieldFormatLen(name, nBits, &aacFrameGroup, inArg)
|
|
case dataFormat == "mp4a" && t.objectType == format.MPEGObjectTypeVORBIS:
|
|
d.FieldFormatLen(name, nBits, &vorbisPacketGroup, inArg)
|
|
case dataFormat == "mp4v" && t.objectType == format.MPEGObjectTypeMPEG2VideoMain:
|
|
d.FieldFormatLen(name, nBits, &mpegPESPacketSampleGroup, inArg)
|
|
case dataFormat == "mp4v" && t.objectType == format.MPEGObjectTypeMJPEG:
|
|
d.FieldFormatLen(name, nBits, &jpegGroup, inArg)
|
|
case dataFormat == "mp4v" && t.objectType == format.MPEGObjectTypePNG:
|
|
d.FieldFormatLen(name, nBits, &pngGroup, inArg)
|
|
case dataFormat == "jpeg":
|
|
d.FieldFormatLen(name, nBits, &jpegGroup, inArg)
|
|
case dataFormat == "apch",
|
|
dataFormat == "apcn",
|
|
dataFormat == "scpa",
|
|
dataFormat == "apco",
|
|
dataFormat == "ap4h":
|
|
d.FieldFormatLen(name, nBits, &proResFrameGroup, inArg)
|
|
default:
|
|
d.FieldRawLen(name, d.BitsLeft())
|
|
}
|
|
})
|
|
}
|
|
|
|
d.FieldStruct("track", func(d *decode.D) {
|
|
d.FieldValueUint("id", uint64(t.id))
|
|
|
|
trackSDDataFormat := "unknown"
|
|
if len(t.sampleDescriptions) > 0 {
|
|
sd := t.sampleDescriptions[0]
|
|
trackSDDataFormat = sd.dataFormat
|
|
if sd.originalFormat != "" {
|
|
trackSDDataFormat = sd.originalFormat
|
|
}
|
|
}
|
|
|
|
d.FieldValueStr("data_format", trackSDDataFormat, dataFormatNames)
|
|
|
|
switch trackSDDataFormat {
|
|
case "lpcm",
|
|
"raw ",
|
|
"twos",
|
|
"sowt",
|
|
"in24",
|
|
"in32",
|
|
"fl23",
|
|
"fl64",
|
|
"alaw",
|
|
"ulaw":
|
|
// TODO: treat raw samples format differently, a bit too much to have one field per sample.
|
|
// maybe in some future fq could have smart array fields
|
|
return
|
|
}
|
|
|
|
d.FieldArray("samples", func(d *decode.D) {
|
|
// TODO: warning? could also be init fragment etc
|
|
|
|
if len(t.stsz) > 0 && len(t.stsc) > 0 && len(t.stco) > 0 {
|
|
stszIndex := 0
|
|
stszEntryNr := 0
|
|
sampleNr := 0
|
|
stscIndex := 0
|
|
stscEntryNr := 0
|
|
stcoIndex := 0
|
|
|
|
stszEntry := t.stsz[stszIndex]
|
|
stscEntry := t.stsc[stscIndex]
|
|
sampleOffset := t.stco[stcoIndex]
|
|
|
|
logStrFn := func() string {
|
|
return fmt.Sprintf("%d: %s: nr=%d: stsz[%d/%d] nr=%d %#v stsc[%d/%d] nr=%d %#v stco[%d/%d]=%d \n",
|
|
t.id,
|
|
trackSDDataFormat,
|
|
sampleNr,
|
|
stszIndex, len(t.stsz), stszEntryNr, stszEntry,
|
|
stscIndex, len(t.stsc), stscEntryNr, stscEntry,
|
|
stcoIndex, len(t.stco), sampleOffset,
|
|
)
|
|
}
|
|
|
|
for stszIndex < len(t.stsz) {
|
|
if stszEntryNr >= stszEntry.count {
|
|
stszIndex++
|
|
if stszIndex >= len(t.stsz) {
|
|
// TODO: warning if unused stsc/stco entries?
|
|
break
|
|
}
|
|
|
|
stszEntry = t.stsz[stszIndex]
|
|
stszEntryNr = 0
|
|
}
|
|
|
|
if stscEntryNr >= stscEntry.samplesPerChunk {
|
|
stscEntryNr = 0
|
|
stcoIndex++
|
|
if stcoIndex >= len(t.stco) {
|
|
d.Fatalf("outside stco: %s", logStrFn())
|
|
}
|
|
sampleOffset = t.stco[stcoIndex]
|
|
|
|
if stscIndex < len(t.stsc)-1 && stcoIndex >= t.stsc[stscIndex+1].firstChunk-1 {
|
|
stscIndex++
|
|
if stscIndex >= len(t.stsc) {
|
|
d.Fatalf("outside stsc: %s", logStrFn())
|
|
}
|
|
stscEntry = t.stsc[stscIndex]
|
|
}
|
|
}
|
|
|
|
// log.Println(logStrFn())
|
|
|
|
decodeSampleRange(d, t, ctx.opts.DecodeSamples, trackSDDataFormat, "sample", sampleOffset*8, stszEntry.size*8, t.formatInArg)
|
|
|
|
sampleOffset += stszEntry.size
|
|
stscEntryNr++
|
|
stszEntryNr++
|
|
sampleNr++
|
|
}
|
|
}
|
|
|
|
sampleNr := 0
|
|
for _, m := range t.moofs {
|
|
for trunNr, trun := range m.truns {
|
|
var senc senc
|
|
if trunNr < len(m.sencs) {
|
|
senc = m.sencs[trunNr]
|
|
}
|
|
sampleOffset := m.offset + trun.dataOffset
|
|
|
|
for trunSampleNr, sz := range trun.samplesSizes {
|
|
dataFormat := trackSDDataFormat
|
|
if m.defaultSampleDescriptionIndex != 0 && m.defaultSampleDescriptionIndex-1 < len(t.sampleDescriptions) {
|
|
sd := t.sampleDescriptions[m.defaultSampleDescriptionIndex-1]
|
|
dataFormat = sd.dataFormat
|
|
if sd.originalFormat != "" {
|
|
dataFormat = sd.originalFormat
|
|
}
|
|
}
|
|
|
|
// logStrFn := func() string {
|
|
// return fmt.Sprintf("%d: %s: %d: (%s): sz=%d %d+%d=%d",
|
|
// t.id,
|
|
// dataFormat,
|
|
// sampleNr,
|
|
// trackSDDataFormat,
|
|
// sz,
|
|
// m.offset,
|
|
// m.dataOffset,
|
|
// sampleOffset,
|
|
// )
|
|
// }
|
|
// log.Println(logStrFn())
|
|
|
|
decodeSample := ctx.opts.DecodeSamples
|
|
if trunSampleNr < len(senc.entries) {
|
|
// TODO: encrypted
|
|
decodeSample = false
|
|
}
|
|
|
|
decodeSampleRange(d, t, decodeSample, dataFormat, "sample", sampleOffset*8, sz*8, t.formatInArg)
|
|
|
|
sampleOffset += sz
|
|
sampleNr++
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func mp4Decode(d *decode.D) any {
|
|
var mi format.MP4_In
|
|
d.ArgAs(&mi)
|
|
|
|
ctx := &decodeContext{
|
|
opts: mi,
|
|
path: []pathEntry{{typ: "root"}},
|
|
tracks: map[int]*track{},
|
|
}
|
|
|
|
// TODO: nicer, validate functions without field?
|
|
d.AssertLeastBytesLeft(16)
|
|
size := d.U32()
|
|
if size < 8 {
|
|
d.Fatalf("first box size too small < 8")
|
|
}
|
|
firstType := d.UTF8(4)
|
|
switch firstType {
|
|
case "styp", // mp4 segment
|
|
"ftyp", // mp4 file
|
|
"free", // seems to happen
|
|
"moov", // seems to happen
|
|
"pnot", // video preview file
|
|
"jP ": // JPEG 2000
|
|
default:
|
|
d.Errorf("no styp, ftyp, free or moov box found")
|
|
}
|
|
|
|
d.SeekRel(-8 * 8)
|
|
|
|
ctx.path = []pathEntry{{typ: "", data: &rootBox{}}}
|
|
|
|
decodeBoxes(ctx, d)
|
|
if len(ctx.tracks) > 0 {
|
|
mp4Tracks(d, ctx)
|
|
}
|
|
|
|
return nil
|
|
}
|