Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `DecodeAudioSampleEntry` now errors on bodies too short for the fixed
fields, and encoding errors on inconsistent
`QuickTimeVersion`/`QuickTimeV1`/`QuickTimeV2` combinations
- The QuickTime audio sample entries `.mp3`, `lpcm`, `twos`, and `sowt`
decode as `AudioSampleEntryBox` (reachable as `StsdBox.Mp3` and
`StsdBox.QtPcm`) instead of falling through to `UnknownBox`. A body that
does not parse as a sound sample description still becomes an
`UnknownBox`, so previously decodable files keep decoding; a body that
does parse now re-encodes with the same fidelity as `mp4a` (zeroed
packet-size bytes, integer sample rate) instead of byte-verbatim. Note
that a `.mp3` entry carries no esds; its codec facts live in the sound
description fields themselves

### Fixed

Expand Down
68 changes: 68 additions & 0 deletions mp4/audiosampleentry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,21 @@ func TestQuickTimeEncodeValidation(t *testing.T) {
}
}

func TestLegacyQuickTimeNamesFallBackToUnknown(t *testing.T) {
// Truncated legacy entries decoded as UnknownBox before the names were
// registered; they must keep doing so instead of failing the file.
data := quickTimeSoundDescriptionBytes(".mp3", 0, nil, nil)[:20]
binary.BigEndian.PutUint32(data[:4], uint32(len(data)))
cmpAfterDecodeEncodeBox(t, data)
box, err := mp4.DecodeBox(0, bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
if _, isUnknown := box.(*mp4.UnknownBox); !isUnknown {
t.Errorf("truncated .mp3 entry decoded as %T, wanted UnknownBox", box)
}
}

// TestDirtyReservedBytesKeepDecoding pins the fallback: entries whose
// reserved bytes carry an unknown version, or claim a QuickTime version the
// layout does not match, decode with the plain ISO layout like before, and
Expand Down Expand Up @@ -307,3 +322,56 @@ func TestQuickTimeLpcmFormatFlags(t *testing.T) {
})
}
}

func TestQuickTimeAudioSampleEntryNames(t *testing.T) {
for _, name := range []string{".mp3", "lpcm", "twos", "sowt"} {
t.Run(name, func(t *testing.T) {
data := quickTimeSoundDescriptionBytes(name, 0, nil, nil)
cmpAfterDecodeEncodeBox(t, data)
box, err := mp4.DecodeBox(0, bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
entry, ok := box.(*mp4.AudioSampleEntryBox)
if !ok {
t.Fatalf("%s decoded as %T, wanted AudioSampleEntryBox", name, box)
}
if entry.ChannelCount != 2 || entry.SampleSize != 16 || entry.SampleRate != 48000 {
t.Errorf("fixed fields not decoded: %+v", entry)
}
stsd := mp4.NewStsdBox()
stsd.AddChild(entry)
switch name {
case ".mp3":
if stsd.Mp3 != entry {
t.Error("stsd.Mp3 not set")
}
default:
if stsd.QtPcm != entry {
t.Error("stsd.QtPcm not set")
}
}
})
}

// lpcm entries carry a version 2 sound description in practice.
t.Run("lpcm version 2", func(t *testing.T) {
v2Extension := make([]byte, 0, 36)
v2Extension = binary.BigEndian.AppendUint32(v2Extension, 72)
v2Extension = binary.BigEndian.AppendUint64(v2Extension, math.Float64bits(44100))
for _, val := range []uint32{2, mp4.QuickTimeV2Marker, 16, 0, 4, 1} {
v2Extension = binary.BigEndian.AppendUint32(v2Extension, val)
}
data := quickTimeSoundDescriptionBytes("lpcm", 2, v2Extension, nil)
cmpAfterDecodeEncodeBox(t, data)
box, err := mp4.DecodeBox(0, bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
entry := box.(*mp4.AudioSampleEntryBox)
q := entry.QuickTimeV2
if q == nil || q.AudioSampleRate != 44100 || q.NumAudioChannels != 2 || q.ConstBitsPerChannel != 16 {
t.Errorf("quickTimeV2 fields not decoded: %+v", q)
}
})
}
32 changes: 32 additions & 0 deletions mp4/audiosamplentry.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,38 @@ func DecodeAudioSampleEntrySR(hdr BoxHeader, startPos uint64, sr bits.SliceReade
return decodeAudioSampleEntryFromData(hdr, startPos, data)
}

// DecodeQuickTimeAudioSampleEntry - decode a legacy QuickTime audio sample
// entry (.mp3, lpcm, twos, sowt). These names decoded as UnknownBox before
// they were registered, so a body that does not parse as a sound sample
// description falls back to UnknownBox instead of failing the file.
func DecodeQuickTimeAudioSampleEntry(hdr BoxHeader, startPos uint64, r io.Reader) (Box, error) {
data, err := readBoxBody(r, hdr)
if err != nil {
return nil, err
}
box, err := decodeAudioSampleEntryFromData(hdr, startPos, data)
if err != nil {
return CreateUnknownBox(hdr.Name, hdr.Size, data), nil
}
return box, nil
}

// DecodeQuickTimeAudioSampleEntrySR - decode a legacy QuickTime audio sample
// entry (.mp3, lpcm, twos, sowt) with UnknownBox fallback.
func DecodeQuickTimeAudioSampleEntrySR(hdr BoxHeader, startPos uint64, sr bits.SliceReader) (Box, error) {
data := sr.ReadBytes(hdr.payloadLen())
if sr.AccError() != nil {
return nil, sr.AccError()
}
box, err := decodeAudioSampleEntryFromData(hdr, startPos, data)
if err != nil {
payload := make([]byte, len(data))
copy(payload, data)
return CreateUnknownBox(hdr.Name, hdr.Size, payload), nil
}
return box, nil
}

// Type - return box type
func (a *AudioSampleEntryBox) Type() string {
return a.name
Expand Down
4 changes: 4 additions & 0 deletions mp4/box.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func init() {
"\xa9nam": DecodeGenericContainerBox,
"\xa9too": DecodeGenericContainerBox,
"\xa9cpy": DecodeGenericContainerBox,
".mp3": DecodeQuickTimeAudioSampleEntry,
"ac-3": DecodeAudioSampleEntry,
"ac-4": DecodeAudioSampleEntry,
"alou": DecodeLoudnessBaseBox,
Expand Down Expand Up @@ -101,6 +102,7 @@ func init() {
"jpgC": DecodeJpgC,
"kind": DecodeKind,
"leva": DecodeLeva,
"lpcm": DecodeQuickTimeAudioSampleEntry,
"ludt": DecodeLudt,
"mdat": DecodeMdat,
"mdcv": DecodeMdcv,
Expand Down Expand Up @@ -148,6 +150,7 @@ func init() {
"skip": DecodeFree,
"SmDm": DecodeSmDm,
"smhd": DecodeSmhd,
"sowt": DecodeQuickTimeAudioSampleEntry,
"ssix": DecodeSsix,
"stbl": DecodeStbl,
"stco": DecodeStco,
Expand Down Expand Up @@ -178,6 +181,7 @@ func init() {
"trex": DecodeTrex,
"trgr": DecodeTrgr,
"trun": DecodeTrun,
"twos": DecodeQuickTimeAudioSampleEntry,
"udta": DecodeUdta,
"url ": DecodeURLBox,
"uuid": DecodeUUIDBox,
Expand Down
4 changes: 4 additions & 0 deletions mp4/boxsr.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func init() {
"\xa9cpy": DecodeGenericContainerBoxSR,
"\xa9nam": DecodeGenericContainerBoxSR,
"\xa9too": DecodeGenericContainerBoxSR,
".mp3": DecodeQuickTimeAudioSampleEntrySR,
"ac-3": DecodeAudioSampleEntrySR,
"ac-4": DecodeAudioSampleEntrySR,
"alou": DecodeLoudnessBaseBoxSR,
Expand Down Expand Up @@ -92,6 +93,7 @@ func init() {
"jpgC": DecodeJpgCSR,
"kind": DecodeKindSR,
"leva": DecodeLevaSR,
"lpcm": DecodeQuickTimeAudioSampleEntrySR,
"ludt": DecodeLudtSR,
"mdat": DecodeMdatSR,
"mdcv": DecodeMdcvSR,
Expand Down Expand Up @@ -139,6 +141,7 @@ func init() {
"skip": DecodeFreeSR,
"SmDm": DecodeSmDmSR,
"smhd": DecodeSmhdSR,
"sowt": DecodeQuickTimeAudioSampleEntrySR,
"ssix": DecodeSsixSR,
"stbl": DecodeStblSR,
"stco": DecodeStcoSR,
Expand Down Expand Up @@ -169,6 +172,7 @@ func init() {
"trex": DecodeTrexSR,
"trgr": DecodeTrgrSR,
"trun": DecodeTrunSR,
"twos": DecodeQuickTimeAudioSampleEntrySR,
"udta": DecodeUdtaSR,
"url ": DecodeURLBoxSR,
"uuid": DecodeUUIDBoxSR,
Expand Down
16 changes: 16 additions & 0 deletions mp4/stsd.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ type StsdBox struct {
Jpeg *VisualSampleEntryBox
// Mp4a is a pointer to a box with name mp4a
Mp4a *AudioSampleEntryBox
// Mp3 is a pointer to a box with name .mp3 (QuickTime MP3 audio)
Mp3 *AudioSampleEntryBox
// QtPcm is a pointer to a box with name lpcm (modern, version 2 style), or
// twos/sowt (legacy big/little-endian 16-bit PCM) — QuickTime PCM audio.
// Check Type() to tell which one it is.
QtPcm *AudioSampleEntryBox
// AC3 is a pointer to a box with name ac-3
AC3 *AudioSampleEntryBox
// EC3 is a pointer to a box with name ec-3
Expand Down Expand Up @@ -93,6 +99,16 @@ func (s *StsdBox) AddChild(box Box) {
s.Avs3 = box.(*VisualSampleEntryBox)
case "mp4a":
s.Mp4a = box.(*AudioSampleEntryBox)
case ".mp3":
// A legacy body that does not parse as a sound sample description
// falls back to UnknownBox; it then only lives in Children.
if entry, ok := box.(*AudioSampleEntryBox); ok {
s.Mp3 = entry
}
case "lpcm", "twos", "sowt":
if entry, ok := box.(*AudioSampleEntryBox); ok {
s.QtPcm = entry
}
case "ac-3":
s.AC3 = box.(*AudioSampleEntryBox)
case "ec-3":
Expand Down
45 changes: 45 additions & 0 deletions mp4/stsd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mp4_test

import (
"bytes"
"encoding/binary"
"encoding/hex"
"os"
"testing"
Expand Down Expand Up @@ -152,6 +153,50 @@ func TestStsdAC4(t *testing.T) {
}
}

// TestStsdTruncatedLegacyAudioEntryFallsBackToUnknown pins that a registered
// legacy QuickTime name whose body does not parse as a sound sample
// description (here a truncated .mp3 entry, which decodes as UnknownBox)
// still lands in Children without being typed as StsdBox.Mp3, on both
// decode paths.
func TestStsdTruncatedLegacyAudioEntryFallsBackToUnknown(t *testing.T) {
entry := quickTimeSoundDescriptionBytes(".mp3", 0, nil, nil)[:20]
binary.BigEndian.PutUint32(entry[:4], uint32(len(entry)))
data := make([]byte, 0, 16+len(entry))
data = binary.BigEndian.AppendUint32(data, uint32(16+len(entry)))
data = append(data, []byte("stsd")...)
data = binary.BigEndian.AppendUint32(data, 0) // version + flags
data = binary.BigEndian.AppendUint32(data, 1) // entry count
data = append(data, entry...)
decodes := []struct {
name string
decode func() (mp4.Box, error)
}{
{"DecodeBox", func() (mp4.Box, error) { return mp4.DecodeBox(0, bytes.NewReader(data)) }},
{"DecodeBoxSR", func() (mp4.Box, error) { return mp4.DecodeBoxSR(0, bits.NewFixedSliceReader(data)) }},
}
for _, d := range decodes {
t.Run(d.name, func(t *testing.T) {
box, err := d.decode()
if err != nil {
t.Fatal(err)
}
stsd, ok := box.(*mp4.StsdBox)
if !ok {
t.Fatalf("Expected StsdBox, got %T", box)
}
if len(stsd.Children) != 1 {
t.Fatalf("Expected one child, got %d", len(stsd.Children))
}
if _, isUnknown := stsd.Children[0].(*mp4.UnknownBox); !isUnknown {
t.Errorf("truncated .mp3 entry decoded as %T, wanted UnknownBox", stsd.Children[0])
}
if stsd.Mp3 != nil {
t.Error("StsdBox.Mp3 is set, wanted nil for an UnknownBox fallback child")
}
})
}
}

func decodeStsdBox(t *testing.T, data []byte) *mp4.StsdBox {
t.Helper()
sr := bits.NewFixedSliceReader(data)
Expand Down
Loading