From 180e766a24e6f9529f6ea1893265a0e9e3ed501f Mon Sep 17 00:00:00 2001
From: langhuihui <178529795@qq.com>
Date: Wed, 5 Feb 2025 18:52:52 +0800
Subject: [PATCH] feat: vod hlsv7 (fmp4)
---
example/default/config.yaml | 21 +-
plugin/hls/hls.js/fmp4.html | 330 +++++++++++++++++++++++++
plugin/hls/index.go | 105 ++++++++
plugin/mp4/index.go | 156 +++++++-----
plugin/mp4/pkg/bits/reader.go | 59 +++++
plugin/mp4/pkg/box/box.go | 20 +-
plugin/mp4/pkg/box/ftyp.go | 4 -
plugin/mp4/pkg/box/mp4table.go | 2 +-
plugin/mp4/pkg/box/mvhd.go | 58 ++---
plugin/mp4/pkg/box/tfdt.go | 12 +-
plugin/mp4/pkg/box/tfhd.go | 32 +--
plugin/mp4/pkg/box/tfra.go | 33 ++-
plugin/mp4/pkg/box/trun.go | 111 ++++++---
plugin/mp4/pkg/demuxer.go | 2 +-
plugin/mp4/pkg/memory_file.go | 81 +++++++
plugin/mp4/pkg/muxer.go | 428 +++++++++++++++++----------------
plugin/mp4/pkg/muxer_test.go | 370 ++++++++++++++++++++++++++++
plugin/mp4/pkg/record.go | 75 +++---
plugin/mp4/pkg/track.go | 329 +++++++++++++++++--------
plugin/webrtc/api.go | 34 ---
website/index.html | 175 --------------
website/main.js | 93 -------
website/style.css | 419 --------------------------------
23 files changed, 1722 insertions(+), 1227 deletions(-)
create mode 100644 plugin/hls/hls.js/fmp4.html
create mode 100644 plugin/mp4/pkg/bits/reader.go
create mode 100644 plugin/mp4/pkg/memory_file.go
create mode 100644 plugin/mp4/pkg/muxer_test.go
delete mode 100644 website/index.html
delete mode 100644 website/main.js
delete mode 100644 website/style.css
diff --git a/example/default/config.yaml b/example/default/config.yaml
index 7ddbda11..ab238064 100755
--- a/example/default/config.yaml
+++ b/example/default/config.yaml
@@ -2,7 +2,10 @@ global:
location:
"^/hdl/(.*)": "/flv/$1"
loglevel: debug
- enablelogin: false
+ admin:
+ enablelogin: false
+ subscribe:
+ subaudio: false
# db:
# dbtype: mysql
# dsn: root:Monibuca#!4@tcp(sh-cynosdbmysql-grp-kxt43lv6.sql.tencentcdb.com:28520)/lkm7s_v5?parseTime=true
@@ -22,9 +25,14 @@ gb28181:
.* : $0
mp4:
# enable: false
- # publish:
- # delayclosetimeout: 3s
-
+ publish:
+ delayclosetimeout: 3s
+ # onpub:
+ # record:
+ # ^live/.+:
+ # fragment: 10s
+ # filepath: record/$0
+ # type: fmp4
onsub:
pull:
^vod_mp4_\d+/(.+)$: $1
@@ -41,12 +49,17 @@ flv:
# ^live/.+:
# fragment: 1m
# filepath: record/$0
+ publish:
+ delayclosetimeout: 3s
onsub:
pull:
^vod_flv_\d+/(.+)$: $1
# pull:
# live/test: https://livecb.alicdn.com/mediaplatform/afb241b3-408c-42dd-b665-04d22b64f9df.flv?auth_key=1734575216-0-0-c62721303ce751c8e5b2c95a2ec242a0&F=pc&source=34675810_null_live_detail&ali_flv_retain=2
hls:
+ # onsub:
+ # pull:
+ # ^vod_hls_\d+/(.+)$: $1
# pull:
# live/test: https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/gear3/prog_index.m3u8
# onpub:
diff --git a/plugin/hls/hls.js/fmp4.html b/plugin/hls/hls.js/fmp4.html
new file mode 100644
index 00000000..0971106e
--- /dev/null
+++ b/plugin/hls/hls.js/fmp4.html
@@ -0,0 +1,330 @@
+
+
+
+
+
+
+ M3U8 to MP4 Player
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugin/hls/index.go b/plugin/hls/index.go
index 6b0e0dae..3d0c8db0 100644
--- a/plugin/hls/index.go
+++ b/plugin/hls/index.go
@@ -39,6 +39,12 @@ func (p *HLSPlugin) OnInit() (err error) {
return
}
+func (p *HLSPlugin) RegisterHandler() map[string]http.HandlerFunc {
+ return map[string]http.HandlerFunc{
+ "/vod/{streamPath...}": p.vod,
+ }
+}
+
func (p *HLSPlugin) OnPullProxyAdd(pullProxy *m7s.PullProxy) any {
d := &m7s.HTTPPullProxy{}
d.PullProxy = pullProxy
@@ -46,6 +52,105 @@ func (p *HLSPlugin) OnPullProxyAdd(pullProxy *m7s.PullProxy) any {
return d
}
+func (config *HLSPlugin) vod(w http.ResponseWriter, r *http.Request) {
+ recordType := "ts"
+ if r.PathValue("streamPath") == "mp4.m3u8" {
+ recordType = "mp4"
+ } else if r.PathValue("streamPath") == "fmp4.m3u8" {
+ recordType = "fmp4"
+ }
+ query := r.URL.Query()
+ fileName := query.Get("streamPath")
+ waitTimeout, err := time.ParseDuration(query.Get("timeout"))
+ if err == nil {
+ config.Debug("request", "fileName", fileName, "timeout", waitTimeout)
+ } else {
+ waitTimeout = time.Second * 10
+ }
+ // waitStart := time.Now()
+ if strings.HasSuffix(r.URL.Path, ".m3u8") {
+ w.Header().Add("Content-Type", "application/vnd.apple.mpegurl")
+ streamPath := strings.TrimSuffix(fileName, ".m3u8")
+ // If memory lookup failed or returned empty, try database
+ startTime, endTime, _ := util.TimeRangeQueryParse(query)
+ if !startTime.IsZero() {
+ if config.DB != nil {
+ var records []m7s.RecordStream
+ if endTime.IsZero() {
+ query := `stream_path = ? AND type = ? AND start_time IS NOT NULL AND end_time > ?`
+ config.DB.Where(query, streamPath, recordType, startTime).Find(&records)
+ } else {
+ query := `stream_path = ? AND type = ? AND start_time IS NOT NULL AND end_time IS NOT NULL AND ? <= end_time AND ? >= start_time`
+ config.DB.Where(query, streamPath, recordType, startTime, endTime).Find(&records)
+ }
+ if len(records) > 0 {
+ playlist := hls.Playlist{
+ Version: 7,
+ Sequence: 0,
+ Targetduration: 90,
+ }
+ var plBuffer util.Buffer
+ playlist.Writer = &plBuffer
+ playlist.Init()
+
+ for _, record := range records {
+ duration := record.EndTime.Sub(record.StartTime).Seconds()
+ playlist.WriteInf(hls.PlaylistInf{
+ Duration: duration,
+ Title: record.FilePath,
+ FilePath: record.FilePath,
+ })
+ }
+ plBuffer.WriteString("#EXT-X-ENDLIST\n")
+ w.Write(plBuffer)
+ return
+ }
+ }
+ }
+
+ // if v, ok := hls.MemoryM3u8.Load(streamPath); ok && v.(string) != "" {
+ // w.Write([]byte(v.(string)))
+ // return
+ // }
+ // for {
+ // if v, ok := hls.MemoryM3u8.Load(streamPath); ok && v.(string) != "" {
+ // w.Write([]byte(v.(string)))
+ // return
+ // }
+ // if waitTimeout > 0 && time.Since(waitStart) < waitTimeout {
+ // config.Server.OnSubscribe(streamPath, r.URL.Query())
+ // time.Sleep(time.Second)
+ // continue
+ // } else {
+ // break
+ // }
+ // }
+ } else if strings.HasSuffix(r.URL.Path, ".mp4") {
+ w.Header().Add("Content-Type", "video/mp4") //video/mp4
+ data, err := os.ReadFile(r.PathValue("streamPath"))
+ if err == nil {
+ w.Write(data)
+ return
+ }
+ // streamPath := path.Dir(fileName)
+ // tsData, ok := hls.MemoryTs.Load(streamPath)
+ // if !ok {
+ // tsData, ok = hls.MemoryTs.Load(path.Dir(streamPath))
+ // }
+ // if ok {
+ // if tsData, ok := tsData.(hls.TsCacher).GetTs(fileName); ok {
+ // switch v := tsData.(type) {
+ // case *hls.TsInMemory:
+ // v.WriteTo(w)
+ // case util.Buffer:
+ // w.Write(v)
+ // }
+ // return
+ // }
+ // }
+ }
+}
+
func (config *HLSPlugin) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fileName := strings.TrimPrefix(r.URL.Path, "/")
query := r.URL.Query()
diff --git a/plugin/mp4/index.go b/plugin/mp4/index.go
index 8783b44b..6ce84bb8 100644
--- a/plugin/mp4/index.go
+++ b/plugin/mp4/index.go
@@ -12,9 +12,9 @@ import (
"m7s.live/v5"
v5 "m7s.live/v5/pkg"
"m7s.live/v5/pkg/codec"
- "m7s.live/v5/pkg/util"
"m7s.live/v5/plugin/mp4/pb"
pkg "m7s.live/v5/plugin/mp4/pkg"
+ "m7s.live/v5/plugin/mp4/pkg/box"
rtmp "m7s.live/v5/plugin/rtmp/pkg"
)
@@ -23,7 +23,10 @@ type MediaContext struct {
conn net.Conn
wto time.Duration
seqNumber uint32
- audio, video TrackContext
+ muxer *pkg.Muxer
+ audio, video *pkg.Track
+ buffer []byte
+ offset int64
}
func (m *MediaContext) Write(p []byte) (n int, err error) {
@@ -33,6 +36,33 @@ func (m *MediaContext) Write(p []byte) (n int, err error) {
return m.Writer.Write(p)
}
+func (m *MediaContext) Read(p []byte) (n int, err error) {
+ if m.offset >= int64(len(m.buffer)) {
+ return 0, io.EOF
+ }
+ n = copy(p, m.buffer[m.offset:])
+ m.offset += int64(n)
+ return
+}
+
+func (m *MediaContext) Seek(offset int64, whence int) (int64, error) {
+ switch whence {
+ case io.SeekStart:
+ m.offset = offset
+ case io.SeekCurrent:
+ m.offset += offset
+ case io.SeekEnd:
+ m.offset = int64(len(m.buffer)) + offset
+ }
+ if m.offset < 0 {
+ m.offset = 0
+ }
+ if m.offset > int64(len(m.buffer)) {
+ m.offset = int64(len(m.buffer))
+ }
+ return m.offset, nil
+}
+
type TrackContext struct {
TrackId uint32
fragment *mp4.Fragment
@@ -146,108 +176,112 @@ func (p *MP4Plugin) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
- initSegment := mp4.CreateEmptyInit()
- initSegment.Moov.Mvhd.NextTrackID = 1
+ if ctx.conn != nil {
+ ctx.Writer = ctx.conn
+ } else {
+ ctx.Writer = w
+ w.(http.Flusher).Flush()
+ }
ctx.wto = p.GetCommonConf().WriteTimeout
- var ftyp *mp4.FtypBox
+ ctx.muxer = pkg.NewMuxer(pkg.FLAG_FRAGMENT)
+ ctx.muxer.WriteInitSegment(ctx.Writer)
var offsetAudio, offsetVideo = 1, 5
- var durAudio, durVideo uint32 = 40, 40
+
if sub.Publisher.HasVideoTrack() {
v := sub.Publisher.VideoTrack.AVTrack
if err = v.WaitReady(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- moov := initSegment.Moov
- trackID := moov.Mvhd.NextTrackID
- moov.Mvhd.NextTrackID++
- newTrak := mp4.CreateEmptyTrak(trackID, 1000, "video", "chi")
- moov.AddChild(newTrak)
- moov.Mvex.AddChild(mp4.CreateTrex(trackID))
- ctx.video.TrackId = trackID
- ftyp = mp4.NewFtyp("isom", 0x200, []string{
- "isom", "iso2", v.ICodecCtx.FourCC().String(), "mp41",
- })
+ var codecID box.MP4_CODEC_TYPE
+ switch v.ICodecCtx.FourCC() {
+ case codec.FourCC_H264:
+ codecID = box.MP4_CODEC_H264
+ case codec.FourCC_H265:
+ codecID = box.MP4_CODEC_H265
+ }
+ ctx.video = ctx.muxer.AddTrack(codecID)
+ ctx.video.Timescale = 1000
+
switch v.ICodecCtx.FourCC() {
case codec.FourCC_H264:
h264Ctx := v.ICodecCtx.GetBase().(*codec.H264Ctx)
- durVideo = uint32(h264Ctx.PacketDuration(nil) / time.Millisecond)
- newTrak.SetAVCDescriptor("avc1", h264Ctx.RecordInfo.SPS, h264Ctx.RecordInfo.PPS, true)
+ ctx.video.ExtraData = h264Ctx.Record
+ ctx.video.Width = uint32(h264Ctx.Width())
+ ctx.video.Height = uint32(h264Ctx.Height())
case codec.FourCC_H265:
h265Ctx := v.ICodecCtx.GetBase().(*codec.H265Ctx)
- durVideo = uint32(h265Ctx.PacketDuration(nil) / time.Millisecond)
- newTrak.SetHEVCDescriptor("hvc1", h265Ctx.RecordInfo.VPS, h265Ctx.RecordInfo.SPS, h265Ctx.RecordInfo.PPS, nil, true)
- case codec.FourCC_AV1:
- //av1Ctx := v.ICodecCtx.GetBase().(*codec.AV1Ctx)
- //durVideo = uint32(av1Ctx.PacketDuration(nil) / time.Millisecond)
+ ctx.video.ExtraData = h265Ctx.Record
+ ctx.video.Width = uint32(h265Ctx.Width())
+ ctx.video.Height = uint32(h265Ctx.Height())
}
}
+
if sub.Publisher.HasAudioTrack() {
a := sub.Publisher.AudioTrack.AVTrack
if err = a.WaitReady(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- moov := initSegment.Moov
- trackID := moov.Mvhd.NextTrackID
- moov.Mvhd.NextTrackID++
- newTrak := mp4.CreateEmptyTrak(trackID, 1000, "audio", "chi")
- moov.AddChild(newTrak)
- moov.Mvex.AddChild(mp4.CreateTrex(trackID))
- ctx.audio.TrackId = trackID
+ var codecID box.MP4_CODEC_TYPE
+ switch a.ICodecCtx.FourCC() {
+ case codec.FourCC_MP4A:
+ codecID = box.MP4_CODEC_AAC
+ }
+ ctx.audio = ctx.muxer.AddTrack(codecID)
+ ctx.audio.Timescale = 1000
audioCtx := a.ICodecCtx.(v5.IAudioCodecCtx)
+ ctx.audio.SampleRate = uint32(audioCtx.GetSampleRate())
+ ctx.audio.ChannelCount = uint8(audioCtx.GetChannels())
+ ctx.audio.SampleSize = uint16(audioCtx.GetSampleSize())
+
switch a.ICodecCtx.FourCC() {
case codec.FourCC_MP4A:
offsetAudio = 2
- aacCtx := a.ICodecCtx.GetBase().(*codec.AACCtx)
- newTrak.SetAACDescriptor(byte(aacCtx.Config.ObjectType), aacCtx.Config.SampleRate)
- case codec.FourCC_ALAW:
- stsd := newTrak.Mdia.Minf.Stbl.Stsd
- pcma := mp4.CreateAudioSampleEntryBox("pcma",
- uint16(audioCtx.GetChannels()),
- uint16(audioCtx.GetSampleSize()), uint16(audioCtx.GetSampleRate()), nil)
- stsd.AddChild(pcma)
- case codec.FourCC_ULAW:
- stsd := newTrak.Mdia.Minf.Stbl.Stsd
- pcmu := mp4.CreateAudioSampleEntryBox("pcmu",
- uint16(audioCtx.GetChannels()),
- uint16(audioCtx.GetSampleSize()), uint16(audioCtx.GetSampleRate()), nil)
- stsd.AddChild(pcmu)
+ ctx.audio.ExtraData = a.ICodecCtx.GetBase().(*codec.AACCtx).ConfigBytes
+ default:
+ offsetAudio = 1
}
}
- if ctx.conn != nil {
- ctx.Writer = ctx.conn
- } else {
- ctx.Writer = w
- w.(http.Flusher).Flush()
+
+ err = ctx.muxer.WriteInitSegment(&ctx)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
}
- ftyp.Encode(&ctx)
- initSegment.Moov.Encode(&ctx)
- var lastATime, lastVTime uint32
+
m7s.PlayBlock(sub, func(audio *rtmp.RTMPAudio) error {
bs := audio.Memory.ToBytes()
if offsetAudio == 2 && bs[1] == 0 {
return nil
}
- if lastATime > 0 {
- durAudio = audio.Timestamp - lastATime
+ sample := box.Sample{
+ Offset: 0,
+ Data: bs[offsetAudio:],
+ Size: len(bs) - offsetAudio,
+ DTS: uint64(audio.Timestamp),
+ PTS: uint64(audio.Timestamp),
+ KeyFrame: true,
}
- ctx.audio.Push(&ctx, audio.Timestamp, durAudio, bs[offsetAudio:], mp4.SyncSampleFlags)
- lastATime = audio.Timestamp
+ ctx.audio.AddSampleEntry(sample)
return nil
}, func(video *rtmp.RTMPVideo) error {
- if lastVTime > 0 {
- durVideo = video.Timestamp - lastVTime
- }
bs := video.Memory.ToBytes()
if ctx, ok := sub.VideoReader.Track.ICodecCtx.(*rtmp.H265Ctx); ok && ctx.Enhanced && bs[0]&0b1111 == rtmp.PacketTypeCodedFrames {
offsetVideo = 8
} else {
offsetVideo = 5
}
- ctx.video.Push(&ctx, video.Timestamp, durVideo, bs[offsetVideo:], util.Conditional(sub.VideoReader.Value.IDR, mp4.SyncSampleFlags, mp4.NonSyncSampleFlags))
- lastVTime = video.Timestamp
+ sample := box.Sample{
+ Offset: 0,
+ Data: bs[offsetVideo:],
+ Size: len(bs) - offsetVideo,
+ DTS: uint64(video.Timestamp),
+ PTS: uint64(video.Timestamp),
+ KeyFrame: sub.VideoReader.Value.IDR,
+ }
+ ctx.video.AddSampleEntry(sample)
return nil
})
}
diff --git a/plugin/mp4/pkg/bits/reader.go b/plugin/mp4/pkg/bits/reader.go
new file mode 100644
index 00000000..1273219a
--- /dev/null
+++ b/plugin/mp4/pkg/bits/reader.go
@@ -0,0 +1,59 @@
+package bits
+
+// Reader is a bit stream reader
+type Reader struct {
+ Data []byte
+ Offset int
+}
+
+// Skip skips n bits
+func (r *Reader) Skip(n int) {
+ r.Offset += n
+}
+
+// ReadBit reads a single bit
+func (r *Reader) ReadBit() (uint, error) {
+ if r.Offset/8 >= len(r.Data) {
+ return 0, nil
+ }
+ b := r.Data[r.Offset/8]
+ v := (b >> (7 - (r.Offset % 8))) & 0x01
+ r.Offset++
+ return uint(v), nil
+}
+
+// ReadExpGolomb reads an Exp-Golomb code
+func (r *Reader) ReadExpGolomb() (uint, error) {
+ leadingZeroBits := 0
+ for {
+ b, err := r.ReadBit()
+ if err != nil {
+ return 0, err
+ }
+ if b == 1 {
+ break
+ }
+ leadingZeroBits++
+ }
+
+ result := uint(1<> 1) + (val & 0x01)) * uint(sign)
+ return int(val), nil
+}
diff --git a/plugin/mp4/pkg/box/box.go b/plugin/mp4/pkg/box/box.go
index 4f484230..6e2ce0e2 100644
--- a/plugin/mp4/pkg/box/box.go
+++ b/plugin/mp4/pkg/box/box.go
@@ -6,8 +6,8 @@ import (
)
const (
- BasicBoxLen = 8
- FullBoxLen = 12
+ BasicBoxLen = 8 // size(4) + type(4)
+ FullBoxLen = 12 // BasicBoxLen + version(1) + flags(3)
)
func f(s string) [4]byte {
@@ -208,3 +208,19 @@ func (box *FullBox) Encode() (int, []byte) {
copy(buf[offset+1:], box.Flags[:])
return offset + 4, buf
}
+
+type TimeToSampleEntry struct {
+ SampleCount uint32
+ SampleDelta uint32
+}
+
+type CompositionTimeToSampleEntry struct {
+ SampleCount uint32
+ SampleOffset int32
+}
+
+type SampleToChunkEntry struct {
+ FirstChunk uint32
+ SamplesPerChunk uint32
+ SampleDescriptionIndex uint32
+}
diff --git a/plugin/mp4/pkg/box/ftyp.go b/plugin/mp4/pkg/box/ftyp.go
index b92cfa05..d661610a 100644
--- a/plugin/mp4/pkg/box/ftyp.go
+++ b/plugin/mp4/pkg/box/ftyp.go
@@ -5,10 +5,6 @@ import (
"io"
)
-func mov_tag(tag [4]byte) uint32 {
- return binary.LittleEndian.Uint32(tag[:])
-}
-
type FileTypeBox struct {
Major_brand [4]byte
Minor_version uint32
diff --git a/plugin/mp4/pkg/box/mp4table.go b/plugin/mp4/pkg/box/mp4table.go
index 9c0933b8..2699b53e 100644
--- a/plugin/mp4/pkg/box/mp4table.go
+++ b/plugin/mp4/pkg/box/mp4table.go
@@ -39,7 +39,7 @@ type TrunEntry struct {
SampleDuration uint32
SampleSize uint32
SampleFlags uint32
- SampleCompositionTimeOffset uint32
+ SampleCompositionTimeOffset int32
}
type SENC struct {
diff --git a/plugin/mp4/pkg/box/mvhd.go b/plugin/mp4/pkg/box/mvhd.go
index af4b75ae..2882ca81 100644
--- a/plugin/mp4/pkg/box/mvhd.go
+++ b/plugin/mp4/pkg/box/mvhd.go
@@ -41,10 +41,13 @@ type MovieHeaderBox struct {
}
func NewMovieHeaderBox() *MovieHeaderBox {
- _, offset := time.Now().Zone()
+ // MP4/QuickTime epoch starts from Jan 1, 1904
+ // Add offset between Unix epoch (1970) and QuickTime epoch (1904)
+ const mp4Epoch = 2082844800 // seconds between 1904 and 1970
+ now := uint64(time.Now().Unix() + mp4Epoch)
return &MovieHeaderBox{
- Creation_time: uint64(time.Now().Unix() + int64(offset) + 0x7C25B080),
- Modification_time: uint64(time.Now().Unix() + int64(offset) + 0x7C25B080),
+ Creation_time: now,
+ Modification_time: now,
Timescale: 1000,
Rate: 0x00010000,
Volume: 0x0100,
@@ -106,44 +109,35 @@ func (mvhd *MovieHeaderBox) Decode(r io.Reader, basebox *BasicBox) (offset int,
}
func (mvhd *MovieHeaderBox) Encode() (int, []byte) {
+ // Always use version 0 for better compatibility
var fullbox = NewFullBox(TypeMVHD, 0)
- if fullbox.Version == 1 {
- fullbox.Box.Size = FullBoxLen + 108
- } else {
- fullbox.Box.Size = FullBoxLen + 96
- }
+ fullbox.Box.Size = FullBoxLen + 96 // version 0 size
offset, buf := fullbox.Encode()
- if fullbox.Version == 1 {
- binary.BigEndian.PutUint64(buf[offset:], mvhd.Creation_time)
- offset += 8
- binary.BigEndian.PutUint64(buf[offset:], mvhd.Modification_time)
- offset += 8
- binary.BigEndian.PutUint32(buf[offset:], mvhd.Timescale)
- offset += 4
- binary.BigEndian.PutUint64(buf[offset:], mvhd.Duration)
- offset += 8
- } else {
- binary.BigEndian.PutUint32(buf[offset:], uint32(mvhd.Creation_time))
- offset += 4
- binary.BigEndian.PutUint32(buf[offset:], uint32(mvhd.Modification_time))
- offset += 4
- binary.BigEndian.PutUint32(buf[offset:], uint32(mvhd.Timescale))
- offset += 4
- binary.BigEndian.PutUint32(buf[offset:], uint32(mvhd.Duration))
- offset += 4
- }
+
+ // Version 0: all 32-bit values
+ binary.BigEndian.PutUint32(buf[offset:], uint32(mvhd.Creation_time))
+ offset += 4
+ binary.BigEndian.PutUint32(buf[offset:], uint32(mvhd.Modification_time))
+ offset += 4
+ binary.BigEndian.PutUint32(buf[offset:], mvhd.Timescale)
+ offset += 4
+ binary.BigEndian.PutUint32(buf[offset:], uint32(mvhd.Duration))
+ offset += 4
+
binary.BigEndian.PutUint32(buf[offset:], mvhd.Rate)
offset += 4
binary.BigEndian.PutUint16(buf[offset:], mvhd.Volume)
offset += 2
- offset += 10
- for i, _ := range mvhd.Matrix {
- binary.BigEndian.PutUint32(buf[offset:], mvhd.Matrix[i])
+ offset += 10 // reserved
+
+ for _, matrix := range mvhd.Matrix {
+ binary.BigEndian.PutUint32(buf[offset:], matrix)
offset += 4
}
- offset += 24
+
+ offset += 24 // pre-defined
binary.BigEndian.PutUint32(buf[offset:], mvhd.Next_track_ID)
- return offset + 2, buf
+ return offset + 4, buf
}
func MakeMvhdBox(trackid uint32, duration uint32) []byte {
diff --git a/plugin/mp4/pkg/box/tfdt.go b/plugin/mp4/pkg/box/tfdt.go
index 5a61759a..bf611d02 100644
--- a/plugin/mp4/pkg/box/tfdt.go
+++ b/plugin/mp4/pkg/box/tfdt.go
@@ -15,16 +15,25 @@ import (
type TrackFragmentBaseMediaDecodeTimeBox struct {
BaseMediaDecodeTime uint64
+ version uint8
}
func NewTrackFragmentBaseMediaDecodeTimeBox(fragStart uint64) *TrackFragmentBaseMediaDecodeTimeBox {
+ version := uint8(0)
+ if fragStart > 0xFFFFFFFF {
+ version = 1
+ }
return &TrackFragmentBaseMediaDecodeTimeBox{
+ version: version,
BaseMediaDecodeTime: fragStart,
}
}
func (tfdt *TrackFragmentBaseMediaDecodeTimeBox) Size() uint64 {
- return FullBoxLen + 8
+ if tfdt.version == 1 {
+ return FullBoxLen + 8 // 8 bytes for base_media_decode_time
+ }
+ return FullBoxLen + 4 // 4 bytes for base_media_decode_time
}
func (tfdt *TrackFragmentBaseMediaDecodeTimeBox) Decode(r io.Reader, size uint32) (offset int, err error) {
@@ -49,6 +58,7 @@ func (tfdt *TrackFragmentBaseMediaDecodeTimeBox) Decode(r io.Reader, size uint32
func (tfdt *TrackFragmentBaseMediaDecodeTimeBox) Encode() (int, []byte) {
fullbox := NewFullBox(TypeTFDT, 1)
+ tfdt.version = fullbox.Version
fullbox.Box.Size = tfdt.Size()
offset, boxdata := fullbox.Encode()
binary.BigEndian.PutUint64(boxdata[offset:], tfdt.BaseMediaDecodeTime)
diff --git a/plugin/mp4/pkg/box/tfhd.go b/plugin/mp4/pkg/box/tfhd.go
index a182302b..77a94201 100644
--- a/plugin/mp4/pkg/box/tfhd.go
+++ b/plugin/mp4/pkg/box/tfhd.go
@@ -16,24 +16,25 @@ import (
// }
const (
- TF_FLAG_BASE_DATA_OFFSET uint32 = 0x000001
+ TF_FLAG_BASE_DATA_OFFSET_PRESENT uint32 = 0x000001
TF_FLAG_SAMPLE_DESCRIPTION_INDEX_PRESENT uint32 = 0x000002
TF_FLAG_DEFAULT_SAMPLE_DURATION_PRESENT uint32 = 0x000008
TF_FLAG_DEFAULT_SAMPLE_SIZE_PRESENT uint32 = 0x000010
- TF_FLAG_DEAAULT_SAMPLE_FLAGS_PRESENT uint32 = 0x000020
+ TF_FLAG_DEFAULT_SAMPLE_FLAGS_PRESENT uint32 = 0x000020
TF_FLAG_DURATION_IS_EMPTY uint32 = 0x010000
- TF_FLAG_DEAAULT_BASE_IS_MOOF uint32 = 0x020000
+ TF_FLAG_DEFAULT_BASE_IS_MOOF uint32 = 0x020000
- //ffmpeg isom.h
+ // Sample flags
MOV_FRAG_SAMPLE_FLAG_DEGRADATION_PRIORITY_MASK uint32 = 0x0000ffff
- MOV_FRAG_SAMPLE_FLAG_IS_NON_SYNC uint32 = 0x00010000
+ MOV_FRAG_SAMPLE_FLAG_IS_NON_SYNC uint32 = 0x10000000
+ MOV_FRAG_SAMPLE_FLAG_IS_SYNC uint32 = 0x00000000
MOV_FRAG_SAMPLE_FLAG_PADDING_MASK uint32 = 0x000e0000
MOV_FRAG_SAMPLE_FLAG_REDUNDANCY_MASK uint32 = 0x00300000
MOV_FRAG_SAMPLE_FLAG_DEPENDED_MASK uint32 = 0x00c00000
MOV_FRAG_SAMPLE_FLAG_DEPENDS_MASK uint32 = 0x03000000
-
- MOV_FRAG_SAMPLE_FLAG_DEPENDS_NO uint32 = 0x02000000
- MOV_FRAG_SAMPLE_FLAG_DEPENDS_YES uint32 = 0x01000000
+ MOV_FRAG_SAMPLE_FLAG_DEPENDS_RESERVED uint32 = 0x03000000
+ MOV_FRAG_SAMPLE_FLAG_DEPENDS_NO uint32 = 0x02000000
+ MOV_FRAG_SAMPLE_FLAG_DEPENDS_YES uint32 = 0x01000000
)
type TrackFragmentHeaderBox struct {
@@ -55,7 +56,7 @@ func NewTrackFragmentHeaderBox(trackid uint32) *TrackFragmentHeaderBox {
func (tfhd *TrackFragmentHeaderBox) Size(thfdFlags uint32) uint64 {
n := uint64(FullBoxLen)
n += 4
- if thfdFlags&TF_FLAG_BASE_DATA_OFFSET > 0 {
+ if thfdFlags&TF_FLAG_BASE_DATA_OFFSET_PRESENT > 0 && thfdFlags&TF_FLAG_DEFAULT_BASE_IS_MOOF == 0 {
n += 8
}
if thfdFlags&TF_FLAG_SAMPLE_DESCRIPTION_INDEX_PRESENT > 0 {
@@ -67,7 +68,7 @@ func (tfhd *TrackFragmentHeaderBox) Size(thfdFlags uint32) uint64 {
if thfdFlags&TF_FLAG_DEFAULT_SAMPLE_SIZE_PRESENT > 0 {
n += 4
}
- if thfdFlags&TF_FLAG_DEAAULT_SAMPLE_FLAGS_PRESENT > 0 {
+ if thfdFlags&TF_FLAG_DEFAULT_SAMPLE_FLAGS_PRESENT > 0 {
n += 4
}
return n
@@ -86,10 +87,11 @@ func (tfhd *TrackFragmentHeaderBox) Decode(r io.Reader, size uint32, moofOffset
tfhd.Track_ID = binary.BigEndian.Uint32(buf[n:])
n += 4
tfhdFlags := uint32(fullbox.Flags[0])<<16 | uint32(fullbox.Flags[1])<<8 | uint32(fullbox.Flags[2])
- if tfhdFlags&uint32(TF_FLAG_BASE_DATA_OFFSET) > 0 {
+ if tfhdFlags&uint32(TF_FLAG_BASE_DATA_OFFSET_PRESENT) > 0 {
tfhd.BaseDataOffset = binary.BigEndian.Uint64(buf[n:])
n += 8
- } else if tfhdFlags&uint32(TF_FLAG_DEAAULT_BASE_IS_MOOF) > 0 {
+ }
+ if tfhdFlags&uint32(TF_FLAG_DEFAULT_BASE_IS_MOOF) > 0 {
tfhd.BaseDataOffset = moofOffset
} else {
//TODO,In some cases, it is wrong
@@ -108,7 +110,7 @@ func (tfhd *TrackFragmentHeaderBox) Decode(r io.Reader, size uint32, moofOffset
tfhd.DefaultSampleSize = binary.BigEndian.Uint32(buf[n:])
n += 4
}
- if tfhdFlags&uint32(TF_FLAG_DEAAULT_SAMPLE_FLAGS_PRESENT) > 0 {
+ if tfhdFlags&uint32(TF_FLAG_DEFAULT_SAMPLE_FLAGS_PRESENT) > 0 {
tfhd.DefaultSampleFlags = binary.BigEndian.Uint32(buf[n:])
n += 4
}
@@ -126,7 +128,7 @@ func (tfhd *TrackFragmentHeaderBox) Encode(tFfFlags uint32) (int, []byte) {
binary.BigEndian.PutUint32(buf[offset:], tfhd.Track_ID)
offset += 4
thfdFlags := uint32(fullbox.Flags[0])<<16 | uint32(fullbox.Flags[1])<<8 | uint32(fullbox.Flags[2])
- if thfdFlags&uint32(TF_FLAG_BASE_DATA_OFFSET) > 0 {
+ if thfdFlags&uint32(TF_FLAG_BASE_DATA_OFFSET_PRESENT) > 0 && thfdFlags&uint32(TF_FLAG_DEFAULT_BASE_IS_MOOF) == 0 {
binary.BigEndian.PutUint64(buf[offset:], tfhd.BaseDataOffset)
offset += 8
}
@@ -142,7 +144,7 @@ func (tfhd *TrackFragmentHeaderBox) Encode(tFfFlags uint32) (int, []byte) {
binary.BigEndian.PutUint32(buf[offset:], tfhd.DefaultSampleSize)
offset += 4
}
- if thfdFlags&uint32(TF_FLAG_DEAAULT_SAMPLE_FLAGS_PRESENT) > 0 {
+ if thfdFlags&uint32(TF_FLAG_DEFAULT_SAMPLE_FLAGS_PRESENT) > 0 {
binary.BigEndian.PutUint32(buf[offset:], tfhd.DefaultSampleFlags)
offset += 4
}
diff --git a/plugin/mp4/pkg/box/tfra.go b/plugin/mp4/pkg/box/tfra.go
index 6a863f52..f80ae71d 100644
--- a/plugin/mp4/pkg/box/tfra.go
+++ b/plugin/mp4/pkg/box/tfra.go
@@ -44,7 +44,15 @@ func NewTrackFragmentRandomAccessBox(trackid uint32) *TrackFragmentRandomAccessB
}
func (tfra *TrackFragmentRandomAccessBox) Size() uint64 {
- return tfra.Box.Size() + 12 + uint64(len(tfra.FragEntrys))*19
+ entrySize := 0
+ if tfra.Box.Version == 1 {
+ entrySize = 16 // 8 bytes for time + 8 bytes for moof_offset
+ } else {
+ entrySize = 8 // 4 bytes for time + 4 bytes for moof_offset
+ }
+ // Add size for traf_number, trun_number, and sample_number
+ entrySize += int(tfra.LengthSizeOfTrafNum + tfra.LengthSizeOfTrunNum + tfra.LengthSizeOfSampleNum + 3)
+ return tfra.Box.Size() + 12 + uint64(len(tfra.FragEntrys)*entrySize) // 12 = 4(track_id) + 4(reserved) + 4(number_of_entry)
}
func (tfra *TrackFragmentRandomAccessBox) Decode(r io.Reader) (offset int, err error) {
@@ -90,7 +98,11 @@ func (tfra *TrackFragmentRandomAccessBox) Encode() (int, []byte) {
offset, boxdata := tfra.Box.Encode()
binary.BigEndian.PutUint32(boxdata[offset:], tfra.TrackID)
offset += 4
- binary.BigEndian.PutUint32(boxdata[offset:], 0)
+ // Pack length size fields into the reserved uint32
+ lengthSizeFlags := uint32(tfra.LengthSizeOfTrafNum&0x03)<<4 |
+ uint32(tfra.LengthSizeOfTrunNum&0x03)<<2 |
+ uint32(tfra.LengthSizeOfSampleNum&0x03)
+ binary.BigEndian.PutUint32(boxdata[offset:], lengthSizeFlags)
offset += 4
binary.BigEndian.PutUint32(boxdata[offset:], uint32(len(tfra.FragEntrys)))
offset += 4
@@ -106,10 +118,19 @@ func (tfra *TrackFragmentRandomAccessBox) Encode() (int, []byte) {
binary.BigEndian.PutUint32(boxdata[offset:], uint32(frag.MoofOffset))
offset += 4
}
- boxdata[offset] = 1
- boxdata[offset+1] = 1
- boxdata[offset+2] = 1
- offset += 3
+ // Write traf_number, trun_number, and sample_number based on length sizes
+ for i := uint8(0); i < tfra.LengthSizeOfTrafNum+1; i++ {
+ boxdata[offset] = 1
+ offset++
+ }
+ for i := uint8(0); i < tfra.LengthSizeOfTrunNum+1; i++ {
+ boxdata[offset] = 1
+ offset++
+ }
+ for i := uint8(0); i < tfra.LengthSizeOfSampleNum+1; i++ {
+ boxdata[offset] = 1
+ offset++
+ }
}
return offset, boxdata
}
diff --git a/plugin/mp4/pkg/box/trun.go b/plugin/mp4/pkg/box/trun.go
index 45c993c5..19ca6a0e 100644
--- a/plugin/mp4/pkg/box/trun.go
+++ b/plugin/mp4/pkg/box/trun.go
@@ -5,7 +5,7 @@ import (
"io"
)
-// aligned(8) class TrackRunBox extends FullBox(‘trun’, version, tr_flags) {
+// aligned(8) class TrackRunBox extends FullBox('trun', version, tr_flags) {
// unsigned int(32) sample_count;
// // the following are optional fields
// signed int(32) data_offset;
@@ -47,109 +47,148 @@ func NewTrackRunBox() *TrackRunBox {
}
func (trun *TrackRunBox) Size(trunFlags uint32) uint64 {
- n := uint64(FullBoxLen)
- n += 4
- if trunFlags&uint32(TR_FLAG_DATA_OFFSET) > 0 {
- n += 4
- }
- if trunFlags&uint32(TR_FLAG_DATA_FIRST_SAMPLE_FLAGS) > 0 {
- n += 4
- }
- if trunFlags&uint32(TR_FLAG_DATA_SAMPLE_DURATION) > 0 {
- n += 4 * uint64(trun.SampleCount)
- }
- if trunFlags&uint32(TR_FLAG_DATA_SAMPLE_SIZE) > 0 {
- n += 4 * uint64(trun.SampleCount)
+ size := uint64(8) // box header
+ size += 4 // version and flags
+ size += 4 // sample count
+
+ // data offset is always present if flag is set
+ if trunFlags&TR_FLAG_DATA_OFFSET != 0 {
+ size += 4
}
- if trunFlags&uint32(TR_FLAG_DATA_SAMPLE_FLAGS) > 0 {
- n += 4 * uint64(trun.SampleCount)
+
+ // first sample flags is present if flag is set
+ if trunFlags&TR_FLAG_DATA_FIRST_SAMPLE_FLAGS != 0 {
+ size += 4
}
- if trunFlags&uint32(TR_FLAG_DATA_SAMPLE_COMPOSITION_TIME) > 0 {
- n += 4 * uint64(trun.SampleCount)
+
+ // calculate size for each sample entry
+ for i := 0; i < int(trun.SampleCount); i++ {
+ // sample duration is present if flag is set
+ if trunFlags&TR_FLAG_DATA_SAMPLE_DURATION != 0 {
+ size += 4
+ }
+ // sample size is present if flag is set
+ if trunFlags&TR_FLAG_DATA_SAMPLE_SIZE != 0 {
+ size += 4
+ }
+ // sample flags is present if flag is set
+ if trunFlags&TR_FLAG_DATA_SAMPLE_FLAGS != 0 {
+ size += 4
+ }
+ // sample composition time offset is present if flag is set
+ if trunFlags&TR_FLAG_DATA_SAMPLE_COMPOSITION_TIME != 0 {
+ size += 4
+ }
}
- return n
+ return size
}
-func (trun *TrackRunBox) Decode(r io.Reader, size uint32, dataOffset uint32) (offset int, err error) {
+func (trun *TrackRunBox) Decode(r io.Reader, size uint32, dataOffset int32) (offset int, err error) {
var fullbox FullBox
if offset, err = fullbox.Decode(r); err != nil {
return
}
buf := make([]byte, size-12)
if _, err = io.ReadFull(r, buf); err != nil {
- return 0, err
+ return
}
+
n := 0
trun.SampleCount = binary.BigEndian.Uint32(buf[n:])
n += 4
+
trunFlags := uint32(fullbox.Flags[0])<<16 | uint32(fullbox.Flags[1])<<8 | uint32(fullbox.Flags[2])
- if trunFlags&uint32(TR_FLAG_DATA_OFFSET) > 0 {
+ if trunFlags&TR_FLAG_DATA_OFFSET != 0 {
trun.Dataoffset = int32(binary.BigEndian.Uint32(buf[n:]))
n += 4
} else {
- trun.Dataoffset = int32(dataOffset)
+ trun.Dataoffset = dataOffset
}
- if trunFlags&uint32(TR_FLAG_DATA_FIRST_SAMPLE_FLAGS) > 0 {
+
+ if trunFlags&TR_FLAG_DATA_FIRST_SAMPLE_FLAGS != 0 {
trun.FirstSampleFlags = binary.BigEndian.Uint32(buf[n:])
n += 4
}
+
trun.EntryList = make([]TrunEntry, trun.SampleCount)
for i := 0; i < int(trun.SampleCount); i++ {
- if trunFlags&uint32(TR_FLAG_DATA_SAMPLE_DURATION) > 0 {
+ if trunFlags&TR_FLAG_DATA_SAMPLE_DURATION != 0 {
trun.EntryList[i].SampleDuration = binary.BigEndian.Uint32(buf[n:])
n += 4
}
- if trunFlags&uint32(TR_FLAG_DATA_SAMPLE_SIZE) > 0 {
+ if trunFlags&TR_FLAG_DATA_SAMPLE_SIZE != 0 {
trun.EntryList[i].SampleSize = binary.BigEndian.Uint32(buf[n:])
n += 4
}
- if trunFlags&uint32(TR_FLAG_DATA_SAMPLE_FLAGS) > 0 {
+ if trunFlags&TR_FLAG_DATA_SAMPLE_FLAGS != 0 {
trun.EntryList[i].SampleFlags = binary.BigEndian.Uint32(buf[n:])
n += 4
}
- if trunFlags&uint32(TR_FLAG_DATA_SAMPLE_COMPOSITION_TIME) > 0 {
- trun.EntryList[i].SampleCompositionTimeOffset = binary.BigEndian.Uint32(buf[n:])
+ if trunFlags&TR_FLAG_DATA_SAMPLE_COMPOSITION_TIME != 0 {
+ if fullbox.Version == 0 {
+ trun.EntryList[i].SampleCompositionTimeOffset = int32(binary.BigEndian.Uint32(buf[n:]))
+ } else {
+ trun.EntryList[i].SampleCompositionTimeOffset = int32(binary.BigEndian.Uint32(buf[n:]))
+ }
n += 4
}
}
+
offset += n
return
}
func (trun *TrackRunBox) Encode(trunFlags uint32) (int, []byte) {
+ // Always use version 1 for signed composition time offsets
fullbox := NewFullBox(TypeTRUN, 1)
fullbox.Box.Size = trun.Size(trunFlags)
+ fullbox.Flags[0] = byte(trunFlags >> 16)
+ fullbox.Flags[1] = byte(trunFlags >> 8)
+ fullbox.Flags[2] = byte(trunFlags)
offset, buf := fullbox.Encode()
+
+ // Write sample count
binary.BigEndian.PutUint32(buf[offset:], trun.SampleCount)
offset += 4
- if trunFlags&uint32(TR_FLAG_DATA_OFFSET) > 0 {
+ // Write data offset if present
+ if trunFlags&TR_FLAG_DATA_OFFSET != 0 {
+ // Write data offset as int32
binary.BigEndian.PutUint32(buf[offset:], uint32(trun.Dataoffset))
offset += 4
}
- if trunFlags&uint32(TR_FLAG_DATA_FIRST_SAMPLE_FLAGS) > 0 {
+
+ // Write first sample flags if present
+ if trunFlags&TR_FLAG_DATA_FIRST_SAMPLE_FLAGS != 0 {
binary.BigEndian.PutUint32(buf[offset:], trun.FirstSampleFlags)
offset += 4
}
+ // Write sample entries in the correct order
for i := 0; i < int(trun.SampleCount); i++ {
- if trunFlags&uint32(TR_FLAG_DATA_SAMPLE_DURATION) != 0 {
+ // Write sample duration if present
+ if trunFlags&TR_FLAG_DATA_SAMPLE_DURATION != 0 {
binary.BigEndian.PutUint32(buf[offset:], trun.EntryList[i].SampleDuration)
offset += 4
}
- if trunFlags&uint32(TR_FLAG_DATA_SAMPLE_SIZE) != 0 {
+ // Write sample size if present
+ if trunFlags&TR_FLAG_DATA_SAMPLE_SIZE != 0 {
binary.BigEndian.PutUint32(buf[offset:], trun.EntryList[i].SampleSize)
offset += 4
}
- if trunFlags&uint32(TR_FLAG_DATA_SAMPLE_FLAGS) != 0 {
+ // Write sample flags if present
+ if trunFlags&TR_FLAG_DATA_SAMPLE_FLAGS != 0 {
binary.BigEndian.PutUint32(buf[offset:], trun.EntryList[i].SampleFlags)
offset += 4
}
- if trunFlags&uint32(TR_FLAG_DATA_SAMPLE_COMPOSITION_TIME) != 0 {
- binary.BigEndian.PutUint32(buf[offset:], trun.EntryList[i].SampleCompositionTimeOffset)
+ // Write sample composition time offset if present
+ if trunFlags&TR_FLAG_DATA_SAMPLE_COMPOSITION_TIME != 0 {
+ // Version 1 uses signed int32 for composition time offset
+ binary.BigEndian.PutUint32(buf[offset:], uint32(trun.EntryList[i].SampleCompositionTimeOffset))
offset += 4
}
}
+
return offset, buf
}
diff --git a/plugin/mp4/pkg/demuxer.go b/plugin/mp4/pkg/demuxer.go
index 5ace5a3a..6badd37a 100644
--- a/plugin/mp4/pkg/demuxer.go
+++ b/plugin/mp4/pkg/demuxer.go
@@ -355,7 +355,7 @@ func (d *Demuxer) Demux() (err error) {
d.currentTrack.StartDts = tfdt.BaseMediaDecodeTime
case TypeTRUN:
var trun TrackRunBox
- trun.Decode(d.reader, uint32(basebox.Size), uint32(d.dataOffset))
+ trun.Decode(d.reader, uint32(basebox.Size), int32(d.dataOffset))
d.decodeTRUN(&trun)
case TypeSENC:
var senc SencBox
diff --git a/plugin/mp4/pkg/memory_file.go b/plugin/mp4/pkg/memory_file.go
new file mode 100644
index 00000000..28294fb1
--- /dev/null
+++ b/plugin/mp4/pkg/memory_file.go
@@ -0,0 +1,81 @@
+package mp4
+
+import (
+ "bytes"
+ "io"
+ "os"
+)
+
+type MemoryFile struct {
+ *bytes.Buffer
+ pos int64
+}
+
+func NewMemoryFile(buf *bytes.Buffer) *MemoryFile {
+ if buf == nil {
+ buf = bytes.NewBuffer(nil)
+ }
+ return &MemoryFile{Buffer: buf}
+}
+
+func (m *MemoryFile) Seek(offset int64, whence int) (int64, error) {
+ var newPos int64
+ switch whence {
+ case io.SeekStart:
+ newPos = offset
+ case io.SeekCurrent:
+ newPos = m.pos + offset
+ case io.SeekEnd:
+ newPos = int64(m.Buffer.Len()) + offset
+ default:
+ return 0, os.ErrInvalid
+ }
+
+ if newPos < 0 {
+ return 0, os.ErrInvalid
+ }
+
+ if newPos > int64(m.Buffer.Len()) {
+ // Extend buffer if seeking beyond end
+ m.Buffer.Write(make([]byte, newPos-int64(m.Buffer.Len())))
+ }
+
+ m.pos = newPos
+ return m.pos, nil
+}
+
+func (m *MemoryFile) Read(p []byte) (n int, err error) {
+ if m.pos >= int64(m.Buffer.Len()) {
+ return 0, io.EOF
+ }
+ n = copy(p, m.Buffer.Bytes()[m.pos:])
+ m.pos += int64(n)
+ if n < len(p) {
+ err = io.EOF
+ }
+ return
+}
+
+func (m *MemoryFile) Write(p []byte) (n int, err error) {
+ // If writing beyond current size, extend the buffer
+ if m.pos > int64(m.Buffer.Len()) {
+ m.Buffer.Write(make([]byte, m.pos-int64(m.Buffer.Len())))
+ }
+
+ // If writing at the end, use Buffer.Write
+ if m.pos == int64(m.Buffer.Len()) {
+ n, err = m.Buffer.Write(p)
+ m.pos += int64(n)
+ return
+ }
+
+ // Otherwise, copy data at the current position
+ n = copy(m.Buffer.Bytes()[m.pos:], p)
+ if n < len(p) {
+ // If we need more space, extend the buffer
+ m.Buffer.Write(p[n:])
+ n = len(p)
+ }
+ m.pos += int64(n)
+ return
+}
diff --git a/plugin/mp4/pkg/muxer.go b/plugin/mp4/pkg/muxer.go
index 808976b7..ad3903da 100644
--- a/plugin/mp4/pkg/muxer.go
+++ b/plugin/mp4/pkg/muxer.go
@@ -2,10 +2,10 @@ package mp4
import (
"encoding/binary"
- "errors"
"io"
"os"
+ "m7s.live/v5/pkg"
. "m7s.live/v5/plugin/mp4/pkg/box"
)
@@ -30,14 +30,6 @@ type (
mdatOffset uint64
mdatSize uint64
}
- FileMuxer struct {
- *Muxer
- *os.File
- }
- FMP4Muxer struct {
- *Muxer
- writer io.WriteSeeker
- }
)
func (m Muxer) isFragment() bool {
@@ -52,58 +44,51 @@ func (m Muxer) has(flag Flag) bool {
return (m.Flag & flag) != 0
}
-func NewFileMuxer(f *os.File) (muxer *FileMuxer, err error) {
- muxer = &FileMuxer{
- File: f,
- Muxer: NewMuxer(0),
- }
- err = muxer.WriteInitSegment(f)
- if err != nil {
- return nil, err
- }
- err = muxer.WriteEmptyMdat(f)
- if err != nil {
- return nil, err
- }
- return
-}
-
-func NewFMP4Muxer(w io.WriteSeeker) *FMP4Muxer {
- muxer := &FMP4Muxer{
- writer: w,
- Muxer: NewMuxer(FLAG_FRAGMENT),
- }
- return muxer
-}
-
func NewMuxer(flag Flag) *Muxer {
return &Muxer{
nextTrackId: 1,
nextFragmentId: 1,
Tracks: make(map[uint32]*Track),
Flag: flag,
+ fragDuration: 2000,
}
}
func (m *Muxer) WriteInitSegment(w io.Writer) (err error) {
var n int
- n, err = w.Write(MakeFtypBox(TypeISOM, 0x200, TypeISOM, TypeISO2, TypeAVC1, TypeMP41))
+ var ftypBox []byte
+ if m.isFragment() {
+ // 对于 FMP4,使用 iso5 作为主品牌,兼容 iso5, iso6, mp41
+ ftypBox = MakeFtypBox(TypeISO5, 0x200, TypeISO5, TypeISO6, TypeMP41)
+ } else {
+ // 对于普通 MP4,使用 isom 作为主品牌
+ ftypBox = MakeFtypBox(TypeISOM, 0x200, TypeISOM, TypeISO2, TypeAVC1, TypeMP41)
+ }
+ n, err = w.Write(ftypBox)
if err != nil {
return
}
m.CurrentOffset = int64(n)
- n, err = w.Write((new(FreeBox)).Encode())
- if err != nil {
- return
+ if !m.isFragment() {
+ n, err = w.Write((new(FreeBox)).Encode())
+ if err != nil {
+ return
+ }
+ m.CurrentOffset += int64(n)
+ err = m.WriteEmptyMdat(w)
+ if err != nil {
+ return
+ }
}
- m.CurrentOffset += int64(n)
return
}
func (m *Muxer) WriteEmptyMdat(w io.Writer) (err error) {
- mdat := BasicBox{Type: TypeMDAT, Size: 8}
+ // Write mdat box header with initial size
+ mdat := MediaDataBox(0)
mdatlen, mdatBox := mdat.Encode()
m.mdatOffset = uint64(m.CurrentOffset + 8)
+ m.mdatSize = 0
var n int
n, err = w.Write(mdatBox[0:mdatlen])
if err != nil {
@@ -113,14 +98,6 @@ func (m *Muxer) WriteEmptyMdat(w io.Writer) (err error) {
return
}
-// func (d *Muxer) WriteInitSegment(w io.Writer) error {
-// _, err := w.Write(MakeFtypBox(TypeISO5, 0x200, TypeISO5, TypeISO6, TypeMP41))
-// if err != nil {
-// return err
-// }
-// return d.writeMoov(w)
-// }
-
func (m *Muxer) AddTrack(cid MP4_CODEC_TYPE) *Track {
track := &Track{
Cid: cid,
@@ -135,85 +112,73 @@ func (m *Muxer) AddTrack(cid MP4_CODEC_TYPE) *Track {
return track
}
-func (m *FMP4Muxer) WriteSample(t *Track, sample Sample) (err error) {
- if sample.Offset, err = t.writer.Seek(0, io.SeekCurrent); err != nil {
- return
- }
- if sample.Size, err = t.writer.Write(sample.Data); err != nil {
- return
- }
- sample.Data = nil
- t.AddSampleEntry(sample)
-
- // isKeyFrag := muxer.movFlag.has(MP4_FLAG_KEYFRAME)
- // if isKeyFrag {
- // if data.KeyFrame && track.duration > 0 {
- // err = muxer.flushFragment()
- // if err != nil {
- // return
- // }
- // if muxer.onNewFragment != nil {
- // muxer.onNewFragment(track.duration, track.startPts, track.startDts)
- // }
- // }
- // }
- return
-}
-
-func (m *FileMuxer) WriteSample(t *Track, sample Sample) (err error) {
- return m.Muxer.WriteSample(m.File, t, sample)
-}
-
func (m *Muxer) WriteSample(w io.Writer, t *Track, sample Sample) (err error) {
- if len(sample.Data) == 0 {
- return errors.New("sample data is empty")
- }
- sample.Offset = m.CurrentOffset
- sample.Size, err = w.Write(sample.Data)
- if err != nil {
- return
+ if m.isFragment() {
+ // For fragmented MP4, write to track's buffer
+ if sample.Offset, err = t.writer.Seek(0, io.SeekCurrent); err != nil {
+ return
+ }
+ if sample.Size, err = t.writer.Write(sample.Data); err != nil {
+ return
+ }
+ defer func() {
+ // For fragmented MP4, check if we should create a new fragment
+ if sample.KeyFrame && t.Duration >= m.fragDuration {
+ err = m.flushFragment(w)
+ }
+ }()
+ } else {
+ // For regular MP4, write directly to output
+ sample.Offset = m.CurrentOffset
+ sample.Size, err = w.Write(sample.Data)
+ if err != nil {
+ return
+ }
+ m.CurrentOffset += int64(sample.Size)
}
- m.CurrentOffset += int64(sample.Size)
sample.Data = nil
t.AddSampleEntry(sample)
return
}
-func (m *FileMuxer) reWriteMdatSize() (err error) {
+func (m *Muxer) reWriteMdatSize(w io.WriteSeeker) (err error) {
m.mdatSize = uint64(m.CurrentOffset) - (m.mdatOffset)
if m.mdatSize+BasicBoxLen > 0xFFFFFFFF {
_, mdatBox := MediaDataBox(m.mdatSize).Encode()
- if _, err = m.Seek(int64(m.mdatOffset-16), io.SeekStart); err != nil {
+ if _, err = w.Seek(int64(m.mdatOffset-16), io.SeekStart); err != nil {
return
}
- if _, err = m.Write(mdatBox); err != nil {
+ if _, err = w.Write(mdatBox); err != nil {
return
}
- if _, err = m.Seek(m.CurrentOffset, io.SeekStart); err != nil {
+ if _, err = w.Seek(m.CurrentOffset, io.SeekStart); err != nil {
return
}
} else {
- if _, err = m.Seek(int64(m.mdatOffset-8), io.SeekStart); err != nil {
+ if _, err = w.Seek(int64(m.mdatOffset-8), io.SeekStart); err != nil {
return
}
tmpdata := make([]byte, 4)
binary.BigEndian.PutUint32(tmpdata, uint32(m.mdatSize)+BasicBoxLen)
- if _, err = m.Write(tmpdata); err != nil {
+ if _, err = w.Write(tmpdata); err != nil {
return
}
- if _, err = m.Seek(m.CurrentOffset, io.SeekStart); err != nil {
+ if _, err = w.Seek(m.CurrentOffset, io.SeekStart); err != nil {
return
}
}
return
}
-func (m *FileMuxer) ReWriteWithMoov(f *os.File) (err error) {
- _, err = m.Seek(0, io.SeekStart)
+func (m *Muxer) ReWriteWithMoov(f io.WriteSeeker, r io.Reader) (err error) {
+ if m.isFragment() {
+ return pkg.ErrSkip
+ }
+ _, err = f.Seek(0, io.SeekStart)
if err != nil {
return
}
- _, err = io.CopyN(f, m, int64(m.mdatOffset)-16)
+ _, err = io.CopyN(f, r, int64(m.mdatOffset)-16)
if err != nil {
return
}
@@ -226,19 +191,32 @@ func (m *FileMuxer) ReWriteWithMoov(f *os.File) (err error) {
if err != nil {
return
}
- _, err = io.CopyN(f, m, int64(m.mdatSize)+16)
+ _, err = io.CopyN(f, r, int64(m.mdatSize)+16)
return
}
func (m *Muxer) makeMvex() []byte {
+ mvex := BasicBox{Type: TypeMVEX}
trexs := make([]byte, 0, 64)
for i := uint32(1); i < m.nextTrackId; i++ {
- trex := NewTrackExtendsBox(m.Tracks[i].TrackId)
- trex.DefaultSampleDescriptionIndex = 1
- _, boxData := trex.Encode()
- trexs = append(trexs, boxData...)
+ if track := m.Tracks[i]; track != nil {
+ trex := NewTrackExtendsBox(track.TrackId)
+ trex.DefaultSampleDescriptionIndex = 1
+ trex.DefaultSampleDuration = 0
+ trex.DefaultSampleSize = 0
+ if track.Cid.IsVideo() {
+ trex.DefaultSampleFlags = 0x00010000 // NonSyncSampleFlags in mp4ff
+ } else {
+ trex.DefaultSampleFlags = 0x02000000 // SyncSampleFlags in mp4ff
+ }
+ _, boxData := trex.Encode()
+ trexs = append(trexs, boxData...)
+ }
}
- return trexs
+ mvex.Size = 8 + uint64(len(trexs))
+ offset, mvexBox := mvex.Encode()
+ copy(mvexBox[offset:], trexs)
+ return mvexBox
}
func (m *Muxer) makeTrak(track *Track) []byte {
@@ -310,166 +288,196 @@ func (m *Muxer) WriteMoov(w io.Writer) (err error) {
copy(moovBox[offset:], trak)
offset += len(trak)
}
- copy(moovBox[offset:], mvex)
+ if mvex != nil {
+ copy(moovBox[offset:], mvex)
+ }
+
+ // Write moov box
_, err = w.Write(moovBox)
m.moov = &moov
+ m.CurrentOffset += int64(moov.Size)
return
}
-func (m *FMP4Muxer) WriteTrailer() (err error) {
- err = m.flushFragment()
- if err != nil {
- return err
- }
- //for _, track := range m.Tracks {
- // if track.Cid.IsAudio() {
- // continue
- // }
- //}
- return m.writeMfra()
-}
+func (m *Muxer) WriteTrailer(file *os.File) (err error) {
+ if m.isFragment() {
+ // Flush any remaining samples
+ if err = m.flushFragment(file); err != nil {
+ return err
+ }
-func (m *FileMuxer) WriteTrailer() (err error) {
- if err = m.reWriteMdatSize(); err != nil {
- return err
- }
- return m.WriteMoov(m.File)
-}
+ // Write mfra box
+ mfraSize := 0
+ tfras := make([][]byte, len(m.Tracks))
+ for i := uint32(1); i < m.nextTrackId; i++ {
+ if track := m.Tracks[i]; track != nil && len(track.fragments) > 0 {
+ tfras[i-1] = track.makeTfraBox()
+ mfraSize += len(tfras[i-1])
+ }
+ }
-func (m *FMP4Muxer) writeMfra() (err error) {
- mfraSize := 0
- tfras := make([][]byte, len(m.Tracks))
- for i := uint32(1); i < m.nextTrackId; i++ {
- tfras[i-1] = m.Tracks[i].makeTfraBox()
- mfraSize += len(tfras[i-1])
- }
+ // Only write mfra if we have fragments
+ if mfraSize > 0 {
+ mfro := MakeMfroBox(uint32(mfraSize) + 16)
+ mfraSize += len(mfro)
+ mfra := BasicBox{Type: TypeMFRA}
+ mfra.Size = 8 + uint64(mfraSize)
+ offset, mfraBox := mfra.Encode()
+ for _, tfra := range tfras {
+ if tfra == nil {
+ continue
+ }
+ copy(mfraBox[offset:], tfra)
+ offset += len(tfra)
+ }
+ copy(mfraBox[offset:], mfro)
+ if _, err = file.Write(mfraBox); err != nil {
+ return err
+ }
+ }
- mfro := MakeMfroBox(uint32(mfraSize) + 16)
- mfraSize += len(mfro)
- mfra := BasicBox{Type: TypeMFRA}
- mfra.Size = 8 + uint64(mfraSize)
- offset, mfraBox := mfra.Encode()
- for _, tfra := range tfras {
- copy(mfraBox[offset:], tfra)
- offset += len(tfra)
+ // Clean up any remaining buffers
+ for i := uint32(1); i < m.nextTrackId; i++ {
+ if track := m.Tracks[i]; track != nil && track.writer != nil {
+ if ws, ok := track.writer.(*Fmp4WriterSeeker); ok {
+ ws.Buffer = nil
+ }
+ }
+ }
+ } else {
+ if err = m.reWriteMdatSize(file); err != nil {
+ return err
+ }
+ return m.WriteMoov(file)
}
- copy(mfraBox[offset:], mfro)
- _, err = m.writer.Write(mfraBox)
- return
+ return nil
}
-func (m *FMP4Muxer) flushFragment() (err error) {
-
- if m.isFragment() {
- if m.nextFragmentId == 1 { //first fragment ,write moov
- _, err := m.writer.Write(MakeFtypBox(TypeISO5, 0x200, TypeISO5, TypeISO6, TypeMP41))
- if err != nil {
- return err
- }
- m.WriteMoov(m.writer)
+func (m *Muxer) flushFragment(w io.Writer) (err error) {
+ // Check if there are any samples to write
+ hasSamples := false
+ for i := uint32(1); i < m.nextTrackId; i++ {
+ if len(m.Tracks[i].Samplelist) > 0 {
+ hasSamples = true
+ break
}
}
+ if !hasSamples {
+ return nil
+ }
- var moofOffset int64
- if moofOffset, err = m.writer.Seek(0, io.SeekCurrent); err != nil {
- return err
+ // Write moov box if not written yet
+ if m.moov == nil {
+ if err = m.WriteMoov(w); err != nil {
+ return err
+ }
}
- var mdatlen uint64 = 0
+ // Calculate mdat size first
+ var mdatSize uint64 = 8 // mdat box header
for i := uint32(1); i < m.nextTrackId; i++ {
if len(m.Tracks[i].Samplelist) == 0 {
continue
}
- for j := 0; j < len(m.Tracks[i].Samplelist); j++ {
- m.Tracks[i].Samplelist[j].Offset += int64(mdatlen)
- }
ws := m.Tracks[i].writer.(*Fmp4WriterSeeker)
- mdatlen += uint64(len(ws.Buffer))
+ mdatSize += uint64(len(ws.Buffer))
}
- mdatlen += 8
- moofSize := 0
+ // Write moof box
mfhd := MakeMfhdBox(m.nextFragmentId)
-
- moofSize += len(mfhd)
trafs := make([][]byte, len(m.Tracks))
+ moofSize := len(mfhd)
+ trunOffsets := make([]int, len(m.Tracks)) // track index -> trun data_offset position in moof box
+ var boxOffset int = 8 + len(mfhd) // 8 for moof header
for i := uint32(1); i < m.nextTrackId; i++ {
- traf := m.Tracks[i].makeTraf(moofOffset, 0)
- moofSize += len(traf)
- trafs[i-1] = traf
- }
-
- moofSize += 8 //moof box
- mfhd = MakeMfhdBox(m.nextFragmentId)
- trafs = make([][]byte, len(m.Tracks))
- for i := uint32(1); i < m.nextTrackId; i++ {
- traf := m.Tracks[i].makeTraf(moofOffset, int64(moofSize)+8) //moofSize + 8(mdat box)
+ if len(m.Tracks[i].Samplelist) == 0 {
+ continue
+ }
+ track := m.Tracks[i]
+ // 传递 moof 偏移和 mdat 大小
+ traf := track.makeTraf(&trunOffsets[int(i-1)]) // +8 for moof box header
+ // Record trun data_offset position: current offset + 16 (after trun header)
trafs[i-1] = traf
+ trunOffsets[int(i-1)] += boxOffset
+ boxOffset += len(traf)
+ moofSize += len(traf)
}
- m.nextFragmentId++
+ // Write moof box
moof := BasicBox{Type: TypeMOOF}
- moof.Size = uint64(moofSize)
+ moof.Size = uint64(moofSize + 8) // Add 8 for moof box header
offset, moofBox := moof.Encode()
copy(moofBox[offset:], mfhd)
offset += len(mfhd)
- for i := range trafs {
- copy(moofBox[offset:], trafs[i])
- offset += len(trafs[i])
- }
-
- mdat := BasicBox{Type: TypeMDAT}
- mdat.Size = 8
- _, mdatBox := mdat.Encode()
-
- if m.isDash() {
- _, err := m.writer.Write(MakeStypBox(TypeMSDH, 0, TypeMSDH, TypeMSIX))
- if err != nil {
- return err
- }
-
- for i := uint32(1); i < m.nextTrackId; i++ {
- sidx := m.Tracks[i].makeSidxBox(52*(m.nextTrackId-1-i), uint32(mdatlen)+uint32(len(moofBox))+52*(m.nextTrackId-i-1))
- _, err := m.writer.Write(sidx)
- if err != nil {
- return err
- }
+ for i, traf := range trafs {
+ if traf == nil {
+ continue
}
+ copy(moofBox[offset:], traf)
+ // Update trun data_offset
+ binary.BigEndian.PutUint32(moofBox[trunOffsets[i]:trunOffsets[i]+4], uint32(moof.Size)+8) // +8 for mdat header
+ offset += len(traf)
}
- _, err = m.writer.Write(moofBox)
- if err != nil {
+ if _, err = w.Write(moofBox); err != nil {
return err
}
- binary.BigEndian.PutUint32(mdatBox, uint32(mdatlen))
- _, err = m.writer.Write(mdatBox)
- if err != nil {
+
+ // Write mdat box
+ mdat := BasicBox{Type: TypeMDAT}
+ mdat.Size = mdatSize
+ offset, mdatBox := mdat.Encode()
+ if _, err = w.Write(mdatBox[:offset]); err != nil {
return err
}
+ // Write sample data
+ var sampleOffset int64 = 0
for i := uint32(1); i < m.nextTrackId; i++ {
- if len(m.Tracks[i].Samplelist) > 0 {
- firstPts := m.Tracks[i].Samplelist[0].PTS
- firstDts := m.Tracks[i].Samplelist[0].DTS
- lastPts := m.Tracks[i].Samplelist[len(m.Tracks[i].Samplelist)-1].PTS
- lastDts := m.Tracks[i].Samplelist[len(m.Tracks[i].Samplelist)-1].DTS
+ if len(m.Tracks[i].Samplelist) == 0 {
+ continue
+ }
+ track := m.Tracks[i]
+ ws := track.writer.(*Fmp4WriterSeeker)
+
+ // Update sample offsets relative to mdat start
+ for j := range track.Samplelist {
+ track.Samplelist[j].Offset = sampleOffset
+ sampleOffset += int64(track.Samplelist[j].Size)
+ }
+
+ if _, err = w.Write(ws.Buffer); err != nil {
+ return err
+ }
+
+ // Record fragment info
+ if len(track.Samplelist) > 0 {
+ firstPts := track.Samplelist[0].PTS
+ firstDts := track.Samplelist[0].DTS
+ lastPts := track.Samplelist[len(track.Samplelist)-1].PTS
+ lastDts := track.Samplelist[len(track.Samplelist)-1].DTS
frag := Fragment{
- Offset: uint64(moofOffset),
- Duration: m.Tracks[i].Duration,
+ Offset: uint64(m.CurrentOffset),
+ Duration: track.Duration,
FirstDts: firstDts,
FirstPts: firstPts,
LastPts: lastPts,
LastDts: lastDts,
}
- m.Tracks[i].fragments = append(m.Tracks[i].fragments, frag)
- }
- ws := m.Tracks[i].writer.(*Fmp4WriterSeeker)
- _, err = m.writer.Write(ws.Buffer)
- if err != nil {
- return err
+ track.fragments = append(track.fragments, frag)
}
+
+ // Clear track buffers
ws.Buffer = ws.Buffer[:0]
ws.Offset = 0
- m.Tracks[i].Samplelist = m.Tracks[i].Samplelist[:0]
+ track.Samplelist = track.Samplelist[:0]
+ track.Duration = 0
}
+ m.CurrentOffset += int64(moof.Size) + int64(mdatSize)
+ m.nextFragmentId++
return nil
}
+
+// SetFragmentDuration sets the target duration for each fragment in milliseconds
+func (m *Muxer) SetFragmentDuration(duration uint32) {
+ m.fragDuration = duration
+}
diff --git a/plugin/mp4/pkg/muxer_test.go b/plugin/mp4/pkg/muxer_test.go
new file mode 100644
index 00000000..14ef3df6
--- /dev/null
+++ b/plugin/mp4/pkg/muxer_test.go
@@ -0,0 +1,370 @@
+package mp4
+
+import (
+ "encoding/binary"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "testing"
+
+ "m7s.live/v5/plugin/mp4/pkg/box"
+)
+
+type (
+ FLVHeader struct {
+ Signature [3]byte
+ Version uint8
+ Flags uint8
+ DataOffset uint32
+ }
+
+ FLVTag struct {
+ TagType uint8
+ DataSize uint32
+ Timestamp uint32
+ StreamID uint32
+ Data []byte
+ }
+)
+
+// validateAndFixAVCC 验证并修复 AVCC 格式的 NALU
+func validateAndFixAVCC(data []byte) ([]byte, error) {
+ if len(data) < 4 {
+ return nil, fmt.Errorf("data too short for AVCC")
+ }
+
+ var pos int
+ var output []byte
+
+ for pos < len(data) {
+ if pos+4 > len(data) {
+ return nil, fmt.Errorf("incomplete NALU length at position %d", pos)
+ }
+
+ // 读取 NALU 长度(4字节,大端序)
+ naluLen := binary.BigEndian.Uint32(data[pos : pos+4])
+
+ // 验证 NALU 长度
+ if naluLen == 0 || pos+4+int(naluLen) > len(data) {
+ return nil, fmt.Errorf("invalid NALU length %d at position %d", naluLen, pos)
+ }
+
+ // 验证 NALU 类型
+ naluType := data[pos+4] & 0x1F
+ if naluType == 0 || naluType > 12 {
+ return nil, fmt.Errorf("invalid NALU type %d at position %d", naluType, pos)
+ }
+
+ // 复制长度前缀和 NALU 数据
+ output = append(output, data[pos:pos+4+int(naluLen)]...)
+ pos += 4 + int(naluLen)
+ }
+
+ return output, nil
+}
+
+func readFLVHeader(r io.Reader) (*FLVHeader, error) {
+ header := &FLVHeader{}
+ if err := binary.Read(r, binary.BigEndian, &header.Signature); err != nil {
+ return nil, fmt.Errorf("error reading signature: %v", err)
+ }
+ if err := binary.Read(r, binary.BigEndian, &header.Version); err != nil {
+ return nil, fmt.Errorf("error reading version: %v", err)
+ }
+ if err := binary.Read(r, binary.BigEndian, &header.Flags); err != nil {
+ return nil, fmt.Errorf("error reading flags: %v", err)
+ }
+ if err := binary.Read(r, binary.BigEndian, &header.DataOffset); err != nil {
+ return nil, fmt.Errorf("error reading data offset: %v", err)
+ }
+
+ // Validate FLV signature
+ if string(header.Signature[:]) != "FLV" {
+ return nil, fmt.Errorf("invalid FLV signature: %s", string(header.Signature[:]))
+ }
+
+ fmt.Printf("FLV Header: Version=%d, Flags=%d, DataOffset=%d\n", header.Version, header.Flags, header.DataOffset)
+ return header, nil
+}
+
+func readFLVTag(r io.Reader) (*FLVTag, error) {
+ tag := &FLVTag{}
+
+ // Read previous tag size (4 bytes)
+ var prevTagSize uint32
+ if err := binary.Read(r, binary.BigEndian, &prevTagSize); err != nil {
+ return nil, err
+ }
+ fmt.Printf("Previous tag size: %d\n", prevTagSize)
+
+ // Read tag type (1 byte)
+ if err := binary.Read(r, binary.BigEndian, &tag.TagType); err != nil {
+ return nil, err
+ }
+ fmt.Printf("Tag type: %d\n", tag.TagType)
+
+ // Read data size (3 bytes)
+ var dataSize [3]byte
+ if _, err := io.ReadFull(r, dataSize[:]); err != nil {
+ return nil, err
+ }
+ tag.DataSize = uint32(dataSize[0])<<16 | uint32(dataSize[1])<<8 | uint32(dataSize[2])
+ fmt.Printf("Data size: %d\n", tag.DataSize)
+
+ // Read timestamp (3 bytes + 1 byte extended)
+ var timestamp [3]byte
+ if _, err := io.ReadFull(r, timestamp[:]); err != nil {
+ return nil, err
+ }
+ var timestampExtended uint8
+ if err := binary.Read(r, binary.BigEndian, ×tampExtended); err != nil {
+ return nil, err
+ }
+ tag.Timestamp = uint32(timestamp[0])<<16 | uint32(timestamp[1])<<8 | uint32(timestamp[2]) | uint32(timestampExtended)<<24
+ fmt.Printf("Timestamp: %d\n", tag.Timestamp)
+
+ // Read stream ID (3 bytes)
+ var streamID [3]byte
+ if _, err := io.ReadFull(r, streamID[:]); err != nil {
+ return nil, err
+ }
+ tag.StreamID = uint32(streamID[0])<<16 | uint32(streamID[1])<<8 | uint32(streamID[2])
+ fmt.Printf("Stream ID: %d\n", tag.StreamID)
+
+ // Read tag data
+ tag.Data = make([]byte, tag.DataSize)
+ if _, err := io.ReadFull(r, tag.Data); err != nil {
+ return nil, err
+ }
+ fmt.Printf("Read tag data of size %d\n", len(tag.Data))
+
+ return tag, nil
+}
+
+func findBoxOffsets(filename string) error {
+ file, err := os.Open(filename)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ type boxInfo struct {
+ name string
+ offset int64
+ size uint32
+ }
+
+ var boxes []boxInfo
+
+ // Read the entire file
+ data, err := io.ReadAll(file)
+ if err != nil {
+ return err
+ }
+
+ // Search for boxes
+ var i int
+ for i < len(data)-8 {
+ // Read box size (4 bytes) and type (4 bytes)
+ size := binary.BigEndian.Uint32(data[i : i+4])
+ boxType := string(data[i+4 : i+8])
+
+ // Validate box size
+ if size < 8 || int64(size) > int64(len(data))-int64(i) {
+ i++
+ continue
+ }
+
+ if boxType == "tfdt" || boxType == "mdat" || boxType == "moof" || boxType == "traf" {
+ boxes = append(boxes, boxInfo{
+ name: boxType,
+ offset: int64(i),
+ size: size,
+ })
+ // Print the entire box content for small boxes
+ if size <= 256 {
+ fmt.Printf("\nFull %s box at offset %d (0x%x):\n", boxType, i, i)
+ // Print box header
+ fmt.Printf("Header: % x\n", data[i:i+8])
+ // Print content in chunks of 32 bytes
+ for j := i + 8; j < i+int(size); j += 32 {
+ end := j + 32
+ if end > i+int(size) {
+ end = i + int(size)
+ }
+ fmt.Printf("Content [%d-%d]: % x\n", j-i, end-i, data[j:end])
+ }
+ }
+ }
+ // Move to the next box
+ i += int(size)
+ }
+
+ // Print box information in order of appearance
+ fmt.Println("\nBox layout:")
+ for _, box := range boxes {
+ fmt.Printf("%s box at offset %d (0x%x), size: %d bytes\n",
+ box.name, box.offset, box.offset, box.size)
+
+ // Print the first few bytes of the box content
+ start := box.offset + 8 // skip size and type
+ end := start + 32
+ if end > box.offset+int64(box.size) {
+ end = box.offset + int64(box.size)
+ }
+ fmt.Printf("%s content: % x\n", box.name, data[start:end])
+
+ // For tfdt box, also print the previous and next 8 bytes
+ if box.name == "tfdt" {
+ prevStart := box.offset - 8
+ if prevStart < 0 {
+ prevStart = 0
+ }
+ nextEnd := box.offset + int64(box.size) + 8
+ if nextEnd > int64(len(data)) {
+ nextEnd = int64(len(data))
+ }
+ fmt.Printf("Context around tfdt:\n")
+ fmt.Printf("Previous 8 bytes: % x\n", data[prevStart:box.offset])
+ fmt.Printf("Next 8 bytes: % x\n", data[box.offset+int64(box.size):nextEnd])
+ }
+ }
+ return nil
+}
+
+func TestFLVToFMP4(t *testing.T) {
+ // Open FLV file
+ flvFile, err := os.Open("/Users/dexter/Movies/frame_counter_4k_60fps.flv")
+ if err != nil {
+ t.Fatalf("Failed to open FLV file: %v", err)
+ }
+ defer flvFile.Close()
+
+ // Create output FMP4 file
+ outFile, err := os.Create("test.mp4")
+ if err != nil {
+ t.Fatalf("Failed to create output file: %v", err)
+ }
+ defer outFile.Close()
+
+ // Create FMP4 muxer
+ muxer := NewMuxer(FLAG_FRAGMENT)
+ muxer.WriteInitSegment(outFile)
+ // Read FLV header
+ header, err := readFLVHeader(flvFile)
+ if err != nil {
+ t.Fatalf("Failed to read FLV header: %v", err)
+ }
+
+ hasVideo := header.Flags&0x01 != 0
+
+ // Skip to the first tag
+ if _, err := flvFile.Seek(int64(header.DataOffset), io.SeekStart); err != nil {
+ t.Fatalf("Failed to seek to first tag: %v", err)
+ }
+
+ // Create tracks
+ var videoTrack *Track
+ if hasVideo {
+ videoTrack = muxer.AddTrack(box.MP4_CODEC_H264)
+ videoTrack.Width = 3840 // 4K resolution
+ videoTrack.Height = 2160
+ videoTrack.Timescale = 1000
+ }
+
+ // Variables to store codec configuration
+ var videoConfig []byte
+ var frameCount int
+
+ // Process FLV tags
+TagLoop:
+ for {
+ tag, err := readFLVTag(flvFile)
+ if err != nil {
+ if err == io.EOF {
+ break
+ }
+ t.Fatalf("Failed to read FLV tag: %v", err)
+ }
+
+ switch tag.TagType {
+ case 9: // Video
+ if !hasVideo || videoTrack == nil {
+ continue
+ }
+
+ codecID := tag.Data[0] & 0x0f
+ frameType := tag.Data[0] >> 4
+ if codecID == 7 { // AVC/H.264
+ if tag.Data[1] == 0 { // AVC sequence header
+ fmt.Println("Found AVC sequence header")
+ videoConfig = tag.Data[5:] // Store AVC config (skip composition time)
+ videoTrack.ExtraData = videoConfig
+ } else if len(videoConfig) > 0 { // Video data
+ if len(tag.Data) <= 5 {
+ fmt.Printf("Skipping empty video sample at timestamp %d\n", tag.Timestamp)
+ continue
+ }
+
+ // Read composition time offset (24 bits, signed)
+ compositionTime := int32(tag.Data[2])<<16 | int32(tag.Data[3])<<8 | int32(tag.Data[4])
+ // Convert 24-bit signed integer to 32-bit signed integer
+ if compositionTime&0x800000 != 0 {
+ compositionTime |= ^0xffffff
+ }
+
+ // 验证和修复 AVCC 格式
+ validData, err := validateAndFixAVCC(tag.Data[5:])
+ if err != nil {
+ fmt.Printf("Warning: Invalid AVCC data at timestamp %d: %v\n", tag.Timestamp, err)
+ continue
+ }
+
+ sample := box.Sample{
+ Data: validData,
+ DTS: uint64(tag.Timestamp),
+ PTS: uint64(int64(tag.Timestamp) + int64(compositionTime)),
+ KeyFrame: frameType == 1,
+ }
+ if err := muxer.WriteSample(outFile, videoTrack, sample); err != nil {
+ t.Fatalf("Failed to write video sample: %v", err)
+ }
+ frameCount++
+
+ if frameCount >= 5 {
+ fmt.Println("Wrote 5 frames, stopping")
+ break TagLoop
+ }
+ }
+ }
+ }
+ }
+
+ // Create sample table boxes before writing trailer
+ if videoTrack != nil {
+ videoTrack.makeStblBox()
+ }
+
+ // Write trailer
+ if err := muxer.WriteTrailer(outFile); err != nil {
+ t.Fatalf("Failed to write trailer: %v", err)
+ }
+
+ fmt.Println("Conversion completed successfully")
+
+ // Find and analyze box positions
+ if err := findBoxOffsets("test.mp4"); err != nil {
+ t.Fatalf("Failed to analyze boxes: %v", err)
+ }
+
+ // Validate the generated MP4 file using MP4Box
+ cmd := exec.Command("MP4Box", "-info", "test.mp4")
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("MP4Box validation failed: %v\nOutput: %s", err, output)
+ }
+ fmt.Printf("MP4Box validation output:\n%s\n", output)
+
+ t.Log("Test completed successfully")
+}
diff --git a/plugin/mp4/pkg/record.go b/plugin/mp4/pkg/record.go
index abb00aee..0bfec439 100644
--- a/plugin/mp4/pkg/record.go
+++ b/plugin/mp4/pkg/record.go
@@ -11,6 +11,7 @@ import (
m7s "m7s.live/v5"
"m7s.live/v5/pkg"
"m7s.live/v5/pkg/codec"
+ "m7s.live/v5/pkg/config"
"m7s.live/v5/pkg/task"
"m7s.live/v5/plugin/mp4/pkg/box"
rtmp "m7s.live/v5/plugin/rtmp/pkg"
@@ -24,52 +25,58 @@ var writeTrailerQueueTask WriteTrailerQueueTask
type writeTrailerTask struct {
task.Task
- muxer *FileMuxer
+ muxer *Muxer
+ file *os.File
}
func (task *writeTrailerTask) Start() (err error) {
- err = task.muxer.WriteTrailer()
+ err = task.muxer.WriteTrailer(task.file)
if err != nil {
task.Error("write trailer", "err", err)
- if errClose := task.muxer.File.Close(); errClose != nil {
- return errClose
+ if task.file != nil {
+ if errClose := task.file.Close(); errClose != nil {
+ return errClose
+ }
}
}
return
}
-func (task *writeTrailerTask) Run() (err error) {
- task.Info("write trailer")
+func (t *writeTrailerTask) Run() (err error) {
+ t.Info("write trailer")
var temp *os.File
temp, err = os.CreateTemp("", "*.mp4")
if err != nil {
- task.Error("create temp file", "err", err)
+ t.Error("create temp file", "err", err)
return
}
defer os.Remove(temp.Name())
- err = task.muxer.ReWriteWithMoov(temp)
+ err = t.muxer.ReWriteWithMoov(temp, t.file)
if err != nil {
- task.Error("rewrite with moov", "err", err)
+ if err == pkg.ErrSkip {
+ return task.ErrTaskComplete
+ }
+ t.Error("rewrite with moov", "err", err)
return
}
- if _, err = task.muxer.File.Seek(0, io.SeekStart); err != nil {
- task.Error("seek file", "err", err)
+ if _, err = t.file.Seek(0, io.SeekStart); err != nil {
+ t.Error("seek file", "err", err)
return
}
if _, err = temp.Seek(0, io.SeekStart); err != nil {
- task.Error("seek temp file", "err", err)
+ t.Error("seek temp file", "err", err)
return
}
- if _, err = io.Copy(task.muxer.File, temp); err != nil {
- task.Error("copy file", "err", err)
+ if _, err = io.Copy(t.file, temp); err != nil {
+ t.Error("copy file", "err", err)
return
}
- if err = task.muxer.File.Close(); err != nil {
- task.Error("close file", "err", err)
+ if err = t.file.Close(); err != nil {
+ t.Error("close file", "err", err)
return
}
if err = temp.Close(); err != nil {
- task.Error("close temp file", "err", err)
+ t.Error("close temp file", "err", err)
}
return
}
@@ -112,13 +119,14 @@ func init() {
m7s.Servers.AddTask(&writeTrailerQueueTask)
}
-func NewRecorder() m7s.IRecorder {
+func NewRecorder(conf config.Record) m7s.IRecorder {
return &Recorder{}
}
type Recorder struct {
m7s.DefaultRecorder
- muxer *FileMuxer
+ muxer *Muxer
+ file *os.File
stream m7s.RecordStream
}
@@ -133,20 +141,20 @@ func (r *Recorder) writeTailer(end time.Time) {
}
writeTrailerQueueTask.AddTask(&writeTrailerTask{
muxer: r.muxer,
+ file: r.file,
}, r.Logger)
}
var CustomFileName = func(job *m7s.RecordJob) string {
- if job.Fragment == 0 {
- return fmt.Sprintf("%s.mp4", job.FilePath)
+ if job.RecConf.Fragment == 0 {
+ return fmt.Sprintf("%s.mp4", job.RecConf.FilePath)
}
- return filepath.Join(job.FilePath, fmt.Sprintf("%d.mp4", time.Now().Unix()))
+ return filepath.Join(job.RecConf.FilePath, fmt.Sprintf("%d.mp4", time.Now().Unix()))
}
func (r *Recorder) createStream(start time.Time) (err error) {
recordJob := &r.RecordJob
sub := recordJob.Subscriber
- var file *os.File
r.stream = m7s.RecordStream{
StartTime: start,
StreamPath: sub.StreamPath,
@@ -164,10 +172,17 @@ func (r *Recorder) createStream(start time.Time) (err error) {
if err = os.MkdirAll(dir, 0755); err != nil {
return
}
- if file, err = os.Create(r.stream.FilePath); err != nil {
+ r.file, err = os.Create(r.stream.FilePath)
+ if err != nil {
return
}
- r.muxer, err = NewFileMuxer(file)
+ if recordJob.RecConf.Type == "fmp4" {
+ r.stream.Type = "fmp4"
+ r.muxer = NewMuxer(FLAG_FRAGMENT)
+ } else {
+ r.muxer = NewMuxer(0)
+ }
+ r.muxer.WriteInitSegment(r.file)
if sub.Publisher.HasAudioTrack() {
r.stream.AudioCodec = sub.Publisher.AudioTrack.ICodecCtx.FourCC().String()
}
@@ -210,7 +225,7 @@ func (r *Recorder) Run() (err error) {
}
checkFragment := func(absTime uint32) (err error) {
- if duration := int64(absTime); time.Duration(duration)*time.Millisecond >= recordJob.Fragment {
+ if duration := int64(absTime); time.Duration(duration)*time.Millisecond >= recordJob.RecConf.Fragment {
now := time.Now()
r.writeTailer(now)
err = r.createStream(now)
@@ -238,7 +253,7 @@ func (r *Recorder) Run() (err error) {
return err
}
}
- if recordJob.Fragment != 0 {
+ if recordJob.RecConf.Fragment != 0 {
err := checkFragment(sub.AudioReader.AbsTime)
if err != nil {
return err
@@ -267,7 +282,7 @@ func (r *Recorder) Run() (err error) {
}
}
dts := sub.AudioReader.AbsTime
- return r.muxer.WriteSample(audioTrack, box.Sample{
+ return r.muxer.WriteSample(r.file, audioTrack, box.Sample{
Data: audio.ToBytes(),
PTS: uint64(dts),
DTS: uint64(dts),
@@ -280,7 +295,7 @@ func (r *Recorder) Run() (err error) {
return err
}
}
- if recordJob.Fragment != 0 {
+ if recordJob.RecConf.Fragment != 0 {
err := checkFragment(sub.VideoReader.AbsTime)
if err != nil {
return err
@@ -348,7 +363,7 @@ func (r *Recorder) Run() (err error) {
return nil
}
}
- return r.muxer.WriteSample(videoTrack, box.Sample{
+ return r.muxer.WriteSample(r.file, videoTrack, box.Sample{
KeyFrame: sub.VideoReader.Value.IDR,
Data: bytes[offset:],
PTS: uint64(sub.VideoReader.AbsTime) + uint64(video.CTS),
diff --git a/plugin/mp4/pkg/track.go b/plugin/mp4/pkg/track.go
index 2f156bfc..afeda004 100644
--- a/plugin/mp4/pkg/track.go
+++ b/plugin/mp4/pkg/track.go
@@ -1,6 +1,7 @@
package mp4
import (
+ "fmt"
"io"
. "m7s.live/v5/plugin/mp4/pkg/box"
@@ -11,27 +12,29 @@ type (
Cid MP4_CODEC_TYPE
TrackId uint32
SampleTable
- Duration uint32
- Height uint32
- Width uint32
- SampleRate uint32
- SampleSize uint16
- SampleCount uint32
- ChannelCount uint8
- Timescale uint32
- StartDts uint64
- EndDts uint64
- StartPts uint64
- EndPts uint64
- Samplelist []Sample
- ELST *EditListBox
- ExtraData []byte
- writer io.WriteSeeker
- fragments []Fragment
- defaultSize uint32
- defaultDuration uint32
- defaultSampleFlags uint32
- baseDataOffset uint64
+ Duration uint32
+ Height uint32
+ Width uint32
+ SampleRate uint32
+ SampleSize uint16
+ SampleCount uint32
+ ChannelCount uint8
+ Timescale uint32
+ StartDts uint64
+ EndDts uint64
+ StartPts uint64
+ EndPts uint64
+ Samplelist []Sample
+ ELST *EditListBox
+ ExtraData []byte
+ writer io.WriteSeeker
+ fragments []Fragment
+ defaultSize uint32
+ defaultDuration uint32
+ defaultSampleFlags uint32
+ baseDataOffset uint64
+ stbl []byte
+ FragmentSequenceNumber uint32
//for subsample
defaultIsProtected uint8
@@ -255,79 +258,182 @@ func (track *Track) makeStsd(handler_type HandlerType) []byte {
}
// fmp4
-func (track *Track) makeTraf(moofOffset int64, moofSize int64) []byte {
- tfhd := track.makeTfhdBox(uint64(moofOffset))
- tfdt := track.makeTfdtBox()
- trun := track.makeTrunBoxes(moofSize)
+func (track *Track) makeTraf(dataOffsetOffset *int) []byte {
+ // Create tfhd box
+ tfFlags := uint32(0)
+ tfFlags |= TF_FLAG_DEFAULT_BASE_IS_MOOF
+ tfFlags |= TF_FLAG_SAMPLE_DESCRIPTION_INDEX_PRESENT
+ tfFlags |= TF_FLAG_DEFAULT_SAMPLE_DURATION_PRESENT
+ tfFlags |= TF_FLAG_DEFAULT_SAMPLE_SIZE_PRESENT
+ tfFlags |= TF_FLAG_DEFAULT_SAMPLE_FLAGS_PRESENT
- traf := BasicBox{Type: TypeTRAF, Size: 8 + uint64(len(tfhd)+len(tfdt)+len(trun))}
+ tfhd := NewTrackFragmentHeaderBox(track.TrackId)
+ tfhd.SampleDescriptionIndex = 1
+
+ // Calculate default sample duration
+ if len(track.Samplelist) > 1 {
+ var totalDuration uint64 = 0
+ var count int = 0
+ for i := 1; i < len(track.Samplelist); i++ {
+ duration := track.Samplelist[i].DTS - track.Samplelist[i-1].DTS
+ if duration > 0 {
+ totalDuration += duration
+ count++
+ }
+ }
+ if count > 0 {
+ tfhd.DefaultSampleDuration = uint32(totalDuration / uint64(count))
+ }
+ }
+
+ // Set default sample size
+ if len(track.Samplelist) > 0 {
+ tfhd.DefaultSampleSize = uint32(track.Samplelist[0].Size)
+ }
+
+ // Set default sample flags
+ if track.Cid.IsVideo() {
+ tfhd.DefaultSampleFlags = MOV_FRAG_SAMPLE_FLAG_DEPENDS_YES | MOV_FRAG_SAMPLE_FLAG_IS_NON_SYNC
+ } else {
+ tfhd.DefaultSampleFlags = MOV_FRAG_SAMPLE_FLAG_DEPENDS_NO
+ }
+
+ // Create tfdt box
+ tfdt := NewTrackFragmentBaseMediaDecodeTimeBox(uint64(track.Samplelist[0].DTS))
+
+ // Calculate traf size first (including header)
+ _, tfhdbox := tfhd.Encode(tfFlags)
+ _, tfdtbox := tfdt.Encode()
+ trafSize := uint64(8) + uint64(len(tfhdbox)) + uint64(len(tfdtbox))
+ // Create trun box with total moof size
+ // moof = header(8) + mfhd(16) + traf
+ // traf = header(8) + tfhd + tfdt + trun
+ // So the data offset should be relative to moof start
+ trun := track.makeTrunBox(0, len(track.Samplelist))
+
+ // Update traf size with trun size
+ trafSize += uint64(len(trun))
+
+ // Create traf box
+ traf := BasicBox{Type: TypeTRAF, Size: trafSize}
offset, boxData := traf.Encode()
- copy(boxData[offset:], tfhd)
- offset += len(tfhd)
- copy(boxData[offset:], tfdt)
- offset += len(tfdt)
+ copy(boxData[offset:], tfhdbox)
+ offset += len(tfhdbox)
+ copy(boxData[offset:], tfdtbox)
+ offset += len(tfdtbox)
copy(boxData[offset:], trun)
+ *dataOffsetOffset = offset + 12 + 4 // 12 for trun header, 4 for trun sample count
offset += len(trun)
+
+ if offset != int(trafSize) {
+ panic("traf box size mismatch")
+ }
+
return boxData
}
func (track *Track) makeTfhdBox(offset uint64) []byte {
- tfFlags := TF_FLAG_SAMPLE_DESCRIPTION_INDEX_PRESENT
- tfFlags |= TF_FLAG_DEAAULT_BASE_IS_MOOF
- tfhd := NewTrackFragmentHeaderBox(track.TrackId)
- tfhd.BaseDataOffset = offset
- if len(track.Samplelist) > 1 {
- tfhd.DefaultSampleDuration = uint32(track.Samplelist[1].DTS - track.Samplelist[0].DTS)
- } else if len(track.Samplelist) == 1 && len(track.fragments) > 0 {
- tfhd.DefaultSampleDuration = uint32(track.Samplelist[0].DTS - track.fragments[len(track.fragments)-1].LastDts)
- } else {
- tfhd.DefaultSampleDuration = 0
- tfFlags |= TF_FLAG_DURATION_IS_EMPTY
+ // Set flags in the correct order
+ tfFlags := uint32(0)
+
+ // Set base is moof flag (0x020000)
+ tfFlags |= TF_FLAG_DEFAULT_BASE_IS_MOOF
+
+ // Then set sample description index flag (0x000002)
+ tfFlags |= TF_FLAG_SAMPLE_DESCRIPTION_INDEX_PRESENT
+
+ // Then set default sample duration flag (0x000008)
+ if len(track.Samplelist) > 0 {
+ // Calculate average duration
+ var totalDuration uint64 = 0
+ var count int = 0
+ for i := 1; i < len(track.Samplelist); i++ {
+ duration := track.Samplelist[i].DTS - track.Samplelist[i-1].DTS
+ if duration > 0 {
+ totalDuration += duration
+ count++
+ }
+ }
+
+ if count > 0 {
+ tfFlags |= TF_FLAG_DEFAULT_SAMPLE_DURATION_PRESENT
+ } else if len(track.fragments) > 0 {
+ lastFrag := track.fragments[len(track.fragments)-1]
+ if lastFrag.Duration > 0 {
+ tfFlags |= TF_FLAG_DEFAULT_SAMPLE_DURATION_PRESENT
+ }
+ }
}
+
+ // Then set default sample size flag (0x000010)
if len(track.Samplelist) > 0 {
- tfFlags |= TF_FLAG_DEAAULT_SAMPLE_FLAGS_PRESENT
- tfFlags |= TF_FLAG_DEFAULT_SAMPLE_DURATION_PRESENT
tfFlags |= TF_FLAG_DEFAULT_SAMPLE_SIZE_PRESENT
+ }
+
+ // Then set default sample flags flag (0x000020)
+ tfFlags |= TF_FLAG_DEFAULT_SAMPLE_FLAGS_PRESENT
+
+ // Create tfhd box
+ tfhd := NewTrackFragmentHeaderBox(track.TrackId)
+
+ // Set default values based on flags
+ if tfFlags&TF_FLAG_DEFAULT_SAMPLE_DURATION_PRESENT != 0 {
+ if len(track.Samplelist) > 1 {
+ var totalDuration uint64 = 0
+ var count int = 0
+ for i := 1; i < len(track.Samplelist); i++ {
+ duration := track.Samplelist[i].DTS - track.Samplelist[i-1].DTS
+ if duration > 0 {
+ totalDuration += duration
+ count++
+ }
+ }
+ if count > 0 {
+ tfhd.DefaultSampleDuration = uint32(totalDuration / uint64(count))
+ }
+ } else if len(track.fragments) > 0 {
+ lastFrag := track.fragments[len(track.fragments)-1]
+ if lastFrag.Duration > 0 {
+ tfhd.DefaultSampleDuration = lastFrag.Duration
+ }
+ }
+ }
+
+ if tfFlags&TF_FLAG_DEFAULT_SAMPLE_SIZE_PRESENT != 0 {
tfhd.DefaultSampleSize = uint32(track.Samplelist[0].Size)
- } else {
- tfhd.DefaultSampleSize = 0
}
- //ffmpeg movenc.c mov_write_tfhd_tag
- if track.Cid.IsVideo() {
- tfhd.DefaultSampleFlags = MOV_FRAG_SAMPLE_FLAG_DEPENDS_YES | MOV_FRAG_SAMPLE_FLAG_IS_NON_SYNC
- } else {
- tfhd.DefaultSampleFlags = MOV_FRAG_SAMPLE_FLAG_DEPENDS_NO
+
+ if tfFlags&TF_FLAG_DEFAULT_SAMPLE_FLAGS_PRESENT != 0 {
+ if track.Cid.IsVideo() {
+ tfhd.DefaultSampleFlags = MOV_FRAG_SAMPLE_FLAG_DEPENDS_YES | MOV_FRAG_SAMPLE_FLAG_IS_NON_SYNC
+ } else {
+ tfhd.DefaultSampleFlags = MOV_FRAG_SAMPLE_FLAG_DEPENDS_NO
+ }
}
+
+ // Store default values for later use
track.defaultDuration = tfhd.DefaultSampleDuration
track.defaultSize = tfhd.DefaultSampleSize
track.defaultSampleFlags = tfhd.DefaultSampleFlags
+
+ // Print tfhd box details
+ fmt.Printf("tfhd box: flags=0x%08x, track_id=%d\n", tfFlags, track.TrackId)
+ fmt.Printf("tfhd box: default_sample_duration=%d, default_sample_size=%d, default_sample_flags=0x%08x\n",
+ tfhd.DefaultSampleDuration, tfhd.DefaultSampleSize, tfhd.DefaultSampleFlags)
+
_, boxData := tfhd.Encode(tfFlags)
+ fmt.Printf("tfhd box first 32 bytes: % 02X\n", boxData[:32])
return boxData
}
func (track *Track) makeTfdtBox() []byte {
tfdt := NewTrackFragmentBaseMediaDecodeTimeBox(uint64(track.Samplelist[0].DTS))
- _, boxData := tfdt.Encode()
- return boxData
-}
-
-func (track *Track) makeTrunBoxes(moofSize int64) []byte {
- boxes := make([]byte, 0, 128)
- start := 0
- end := 0
- for i := 1; i < len(track.Samplelist); i++ {
- if track.Samplelist[i].Offset == track.Samplelist[i-1].Offset+int64(track.Samplelist[i-1].Size) {
- continue
- }
- end = i
- boxes = append(boxes, track.makeTrunBox(start, end, moofSize)...)
- start = end
+ offset, boxData := tfdt.Encode()
+ expectedSize := tfdt.Size()
+ if uint64(offset) != expectedSize {
+ panic("tfdt box size is wrong")
}
-
- if start < len(track.Samplelist) {
- boxes = append(boxes, track.makeTrunBox(start, len(track.Samplelist), moofSize)...)
- }
- return boxes
+ return boxData[:offset]
}
func (track *Track) makeStssBox() (boxdata []byte) {
@@ -356,51 +462,68 @@ func (track *Track) makeTfraBox() []byte {
return tfraData
}
-func (track *Track) makeTrunBox(start, end int, moofSize int64) []byte {
+func (track *Track) makeTrunBox(start, end int) []byte {
+ // Create a new TrackRunBox
+ trun := NewTrackRunBox()
+ trun.SampleCount = uint32(end - start)
+
+ // Set flags in the correct order
flag := TR_FLAG_DATA_OFFSET
- if track.Cid.IsVideo() && track.Samplelist[start].KeyFrame {
- flag |= TR_FLAG_DATA_FIRST_SAMPLE_FLAGS
+
+ // Then set sample duration flag (0x000100)
+ flag |= TR_FLAG_DATA_SAMPLE_DURATION
+
+ // Then set sample size flag (0x000200)
+ flag |= TR_FLAG_DATA_SAMPLE_SIZE
+
+ // Then set sample flags if needed (0x000400)
+ if track.Cid.IsVideo() {
+ flag |= TR_FLAG_DATA_SAMPLE_FLAGS
}
- for j := start; j < end; j++ {
- if track.Samplelist[j].Size != int(track.defaultSize) {
- flag |= TR_FLAG_DATA_SAMPLE_SIZE
- }
- if j+1 < end {
- if track.Samplelist[j+1].DTS-track.Samplelist[j].DTS != uint64(track.defaultDuration) {
- flag |= TR_FLAG_DATA_SAMPLE_DURATION
- }
- } else {
- // if track.lastSample.DTS-track.Samplelist[j].DTS != uint64(track.defaultDuration) {
- // flag |= TR_FLAG_DATA_SAMPLE_DURATION
- // }
- }
- if track.Samplelist[j].PTS != track.Samplelist[j].DTS {
+ // Finally set composition time offset flag if needed (0x000800)
+ for i := start; i < end; i++ {
+ if track.Samplelist[i].PTS != track.Samplelist[i].DTS {
flag |= TR_FLAG_DATA_SAMPLE_COMPOSITION_TIME
+ break
}
}
- trun := NewTrackRunBox()
- trun.SampleCount = uint32(end - start)
-
- trun.Dataoffset = int32(moofSize + track.Samplelist[start].Offset)
- trun.FirstSampleFlags = MOV_FRAG_SAMPLE_FLAG_DEPENDS_NO
- for i := start; i < end; i++ {
- sampleDuration := uint32(0)
- if i == len(track.Samplelist)-1 {
- sampleDuration = track.defaultDuration
+ // Fill entry list
+ trun.EntryList = make([]TrunEntry, trun.SampleCount)
+ for i := 0; i < int(trun.SampleCount); i++ {
+ sample := &track.Samplelist[start+i]
+ // Duration
+ if i < int(trun.SampleCount)-1 {
+ trun.EntryList[i].SampleDuration = uint32(track.Samplelist[start+i+1].DTS - sample.DTS)
} else {
- sampleDuration = uint32(track.Samplelist[i+1].DTS - track.Samplelist[i].DTS)
+ trun.EntryList[i].SampleDuration = trun.EntryList[i-1].SampleDuration
}
-
- entry := TrunEntry{
- SampleDuration: sampleDuration,
- SampleSize: uint32(track.Samplelist[i].Size),
- SampleCompositionTimeOffset: uint32(track.Samplelist[i].PTS - track.Samplelist[i].DTS),
+ // Size
+ trun.EntryList[i].SampleSize = uint32(sample.Size)
+ // Flags
+ if flag&TR_FLAG_DATA_SAMPLE_FLAGS != 0 {
+ if sample.KeyFrame {
+ trun.EntryList[i].SampleFlags = MOV_FRAG_SAMPLE_FLAG_DEPENDS_NO | MOV_FRAG_SAMPLE_FLAG_IS_SYNC
+ } else {
+ trun.EntryList[i].SampleFlags = MOV_FRAG_SAMPLE_FLAG_DEPENDS_YES | MOV_FRAG_SAMPLE_FLAG_IS_NON_SYNC
+ }
+ }
+ // Composition time offset
+ if flag&TR_FLAG_DATA_SAMPLE_COMPOSITION_TIME != 0 {
+ trun.EntryList[i].SampleCompositionTimeOffset = int32(sample.PTS - sample.DTS)
}
- trun.EntryList = append(trun.EntryList, entry)
}
+
+ // Calculate data offset
+ // Data offset is relative to the start of the moof box
+ // When TF_FLAG_DEFAULT_BASE_IS_MOOF is set, we need to add the size of the moof box
+ // to point to the start of the data in the mdat box
+ // trun.Dataoffset = int32(moofSize + 8) // +8 for mdat header
+
+ // Print trun box details
_, boxData := trun.Encode(flag)
+
return boxData
}
diff --git a/plugin/webrtc/api.go b/plugin/webrtc/api.go
index ed42f1d5..168baa00 100644
--- a/plugin/webrtc/api.go
+++ b/plugin/webrtc/api.go
@@ -251,37 +251,3 @@ func (conf *WebRTCPlugin) Batch(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
}
}
-
-// 在Connection结构体中添加状态标记
-type Connection struct {
- // ...其他字段
- initialOffer bool
-}
-
-func (conn *Connection) GetAnswer() (SessionDescription, error) {
- if conn.initialOffer {
- // 完整SDP处理
- answer, err := conn.PeerConnection.CreateAnswer(nil)
- conn.initialOffer = false
- return answer, err
- } else {
- // 增量更新时生成部分SDP
- return SessionDescription{
- SDP: conn.generatePartialSDP(),
- Type: SDPTypeAnswer,
- }, nil
- }
-}
-
-// 生成部分SDP的逻辑
-func (conn *Connection) generatePartialSDP() string {
- var sdp strings.Builder
- // 这里简化实现,实际需要根据变化生成对应媒体部分
- sdp.WriteString("v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\n")
- for _, transceiver := range conn.PeerConnection.GetTransceivers() {
- if transceiver.Direction() == RTPTransceiverDirectionRecvonly {
- sdp.WriteString(fmt.Sprintf("m=video 9 UDP/TLS/RTP/SAVPF 96\r\na=recvonly\r\n"))
- }
- }
- return sdp.String()
-}
diff --git a/website/index.html b/website/index.html
deleted file mode 100644
index 96a6aa41..00000000
--- a/website/index.html
+++ /dev/null
@@ -1,175 +0,0 @@
-
-
-
-
-
-
- Monibuca - 高性能流媒体服务器框架
-
-
-
-
-
-
-
-
-
-
-
-
下一代流媒体服务器框架
-
高性能、可扩展、插件化的纯 Go 流媒体服务器开发框架
-
-
-
-
🚀
-
高性能
-
采用纯 Go 开发,充分利用 Go 的并发特性
-
-
-
🔌
-
插件化
-
核心功能都以插件形式提供,可按需加载
-
-
-
🛠
-
可扩展
-
支持自定义插件开发,灵活扩展功能
-
-
-
📽
-
多协议
-
支持 RTMP、HTTP-FLV、HLS、WebRTC 等
-
-
-
-
-
-
-
-
核心特性
-
-
-
🎯 低延迟
-
针对实时性场景优化,提供极低的传输延迟
-
-
-
📊 实时监控
-
支持 Prometheus 监控集成,实时掌握系统状态
-
-
-
🔄 集群支持
-
支持分布式部署,轻松扩展系统规模
-
-
-
-
-
-
-
-
快速开始
-
-
-
mkdir my-m7s-server && cd my-m7s-server
-go mod init my-m7s-server
-
-
-
-
package main
-
-import (
- "context"
- "m7s.live/v5"
- _ "m7s.live/v5/plugin/rtmp"
- _ "m7s.live/v5/plugin/flv"
- _ "m7s.live/v5/plugin/hls"
-)
-
-func main() {
- m7s.Run(context.Background(), "config.yaml")
-}
-
-
-
-
-
-
-
官方插件
-
-
-
-
HTTP-FLV
-
支持 HTTP-FLV 协议直播
-
-
-
-
WebRTC
-
支持 WebRTC 协议互动直播
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/website/main.js b/website/main.js
deleted file mode 100644
index 9a72e36f..00000000
--- a/website/main.js
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copy button functionality
-document.querySelectorAll('.copy-button').forEach(button => {
- button.addEventListener('click', () => {
- const codeId = button.getAttribute('data-target');
- const codeElement = document.getElementById(codeId);
- const text = codeElement.textContent;
-
- navigator.clipboard.writeText(text).then(() => {
- const originalText = button.textContent;
- button.textContent = '已复制!';
- button.style.background = 'rgba(0,255,0,0.2)';
-
- setTimeout(() => {
- button.textContent = originalText;
- button.style.background = 'transparent';
- }, 2000);
- });
- });
-});
-
-// Smooth scrolling for anchor links
-document.querySelectorAll('a[href^="#"]').forEach(anchor => {
- anchor.addEventListener('click', function (e) {
- e.preventDefault();
- const target = document.querySelector(this.getAttribute('href'));
- if (target) {
- target.scrollIntoView({
- behavior: 'smooth',
- block: 'start'
- });
- }
- });
-});
-
-// Header scroll effect
-const header = document.querySelector('header');
-let lastScrollY = window.scrollY;
-
-window.addEventListener('scroll', () => {
- if (window.scrollY > lastScrollY) {
- header.style.transform = 'translateY(-100%)';
- } else {
- header.style.transform = 'translateY(0)';
- }
- lastScrollY = window.scrollY;
-});
-
-// Add transition to header
-header.style.transition = 'transform 0.3s ease-in-out';
-
-// Mobile menu functionality (if needed in the future)
-// const mobileMenuButton = document.querySelector('.mobile-menu-button');
-// const navLinks = document.querySelector('.nav-links');
-
-// if (mobileMenuButton) {
-// mobileMenuButton.addEventListener('click', () => {
-// navLinks.classList.toggle('active');
-// });
-// }
-
-// Intersection Observer for fade-in animations
-const observerOptions = {
- root: null,
- rootMargin: '0px',
- threshold: 0.1
-};
-
-const observer = new IntersectionObserver((entries) => {
- entries.forEach(entry => {
- if (entry.isIntersecting) {
- entry.target.classList.add('fade-in');
- observer.unobserve(entry.target);
- }
- });
-}, observerOptions);
-
-// Observe all sections
-document.querySelectorAll('section').forEach(section => {
- section.style.opacity = '0';
- section.style.transform = 'translateY(20px)';
- section.style.transition = 'opacity 0.6s ease-out, transform 0.6s ease-out';
- observer.observe(section);
-});
-
-// Add fade-in class for animation
-const style = document.createElement('style');
-style.textContent = `
- .fade-in {
- opacity: 1 !important;
- transform: translateY(0) !important;
- }
-`;
-document.head.appendChild(style);
\ No newline at end of file
diff --git a/website/style.css b/website/style.css
deleted file mode 100644
index aadb6258..00000000
--- a/website/style.css
+++ /dev/null
@@ -1,419 +0,0 @@
-:root {
- --primary-color: #646cff;
- --primary-color-dark: #535bf2;
- --text-color: #213547;
- --text-color-light: #666;
- --background-color: #242424;
- --text-color-dark: rgba(255, 255, 255, 0.87);
- --text-color-dark-2: rgba(255, 255, 255, 0.6);
- --border-color: #eee;
- --code-background: #1a1a1a;
-}
-
-* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
-}
-
-body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
- color: var(--text-color-dark);
- line-height: 1.6;
- background: var(--background-color);
- min-height: 100vh;
-}
-
-.container {
- max-width: 1200px;
- margin: 0 auto;
- padding: 0 2rem;
-}
-
-/* Header & Navigation */
-header {
- background: rgba(36, 36, 36, 0.8);
- -webkit-backdrop-filter: blur(12px);
- backdrop-filter: blur(12px);
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
- position: fixed;
- width: 100%;
- top: 0;
- z-index: 1000;
-}
-
-nav {
- height: 64px;
- display: flex;
- align-items: center;
-}
-
-nav .container {
- display: flex;
- justify-content: space-between;
- align-items: center;
- width: 100%;
-}
-
-.logo {
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.logo img {
- height: 32px;
-}
-
-.logo span {
- font-size: 1.5rem;
- font-weight: 600;
- color: var(--primary-color);
-}
-
-.nav-links {
- display: flex;
- gap: 2rem;
-}
-
-.nav-links a {
- text-decoration: none;
- color: var(--text-color-dark-2);
- font-weight: 500;
- transition: color 0.2s;
-}
-
-.nav-links a:hover {
- color: var(--text-color-dark);
-}
-
-.github-link {
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-/* Hero Section */
-.hero {
- padding: 120px 0 80px;
- text-align: center;
- position: relative;
- overflow: hidden;
-}
-
-.hero::before {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background:
- radial-gradient(circle at 50% 0%, rgba(100, 108, 255, 0.15), rgba(36, 36, 36, 0) 50%),
- radial-gradient(circle at 0% 100%, rgba(255, 182, 255, 0.1), rgba(36, 36, 36, 0) 50%),
- radial-gradient(circle at 100% 100%, rgba(100, 108, 255, 0.1), rgba(36, 36, 36, 0) 50%);
- z-index: -1;
-}
-
-.hero h1 {
- font-size: 3.5rem;
- font-weight: 800;
- margin-bottom: 1rem;
- background: linear-gradient(120deg, #bd34fe 30%, #47caff);
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
-}
-
-.hero .subtitle {
- font-size: 1.5rem;
- color: var(--text-color-dark-2);
- margin-bottom: 2rem;
-}
-
-.cta-buttons {
- display: flex;
- gap: 1rem;
- justify-content: center;
- margin-bottom: 4rem;
-}
-
-.primary-button, .secondary-button {
- padding: 0.75rem 2rem;
- border-radius: 20px;
- font-weight: 600;
- text-decoration: none;
- transition: all 0.2s;
- border: 1px solid transparent;
-}
-
-.primary-button {
- background: linear-gradient(to right, #bd34fe 30%, #47caff);
- color: white;
- box-shadow: 0 2px 12px rgba(189, 52, 254, 0.3);
-}
-
-.primary-button:hover {
- background: linear-gradient(to right, #a925e5 30%, #38b8eb);
- transform: translateY(-2px);
- box-shadow: 0 4px 16px rgba(189, 52, 254, 0.4);
-}
-
-.secondary-button {
- background: rgba(255, 255, 255, 0.1);
- color: var(--text-color-dark);
- border: 1px solid rgba(255, 255, 255, 0.2);
-}
-
-.secondary-button:hover {
- background: rgba(255, 255, 255, 0.15);
- transform: translateY(-2px);
- border: 1px solid rgba(255, 255, 255, 0.3);
-}
-
-/* Features Grid */
-.features-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
- gap: 2rem;
- margin-top: 4rem;
-}
-
-.feature-card {
- background: var(--code-background);
- padding: 2rem;
- border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.1);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
- transition: all 0.2s;
-}
-
-.feature-card:hover {
- transform: translateY(-5px);
- border-color: rgba(255, 255, 255, 0.2);
- box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
-}
-
-.feature-card .icon {
- font-size: 2.5rem;
- margin-bottom: 1rem;
-}
-
-.feature-card h3 {
- margin-bottom: 0.5rem;
- color: #bd34fe;
-}
-
-.feature-card p {
- color: var(--text-color-dark-2);
-}
-
-/* Features Section */
-.features {
- padding: 80px 0;
- background: var(--background-color);
- position: relative;
-}
-
-.features::before {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background:
- radial-gradient(circle at 0% 0%, rgba(189, 52, 254, 0.15), rgba(36, 36, 36, 0) 50%),
- radial-gradient(circle at 100% 0%, rgba(71, 202, 255, 0.15), rgba(36, 36, 36, 0) 50%);
- z-index: -1;
-}
-
-.features h2 {
- color: var(--text-color-dark);
-}
-
-.features-list {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
- gap: 2rem;
-}
-
-.feature {
- background: var(--code-background);
- border: 1px solid rgba(255, 255, 255, 0.1);
-}
-
-.feature h3 {
- color: #47caff;
-}
-
-.feature p {
- color: var(--text-color-dark-2);
-}
-
-/* Quickstart Section */
-.quickstart {
- padding: 80px 0;
-}
-
-.quickstart h2 {
- text-align: center;
- margin-bottom: 3rem;
- font-size: 2.5rem;
-}
-
-.code-block {
- background: var(--code-background);
- border-radius: 8px;
- margin-bottom: 2rem;
- overflow: hidden;
-}
-
-.code-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 0.75rem 1rem;
- background: rgba(255,255,255,0.1);
-}
-
-.code-header span {
- color: #fff;
-}
-
-.copy-button {
- background: transparent;
- border: 1px solid rgba(255,255,255,0.2);
- color: #fff;
- padding: 0.25rem 0.75rem;
- border-radius: 4px;
- cursor: pointer;
- transition: all 0.2s;
-}
-
-.copy-button:hover {
- background: rgba(255,255,255,0.1);
-}
-
-pre {
- margin: 0;
- padding: 1.5rem;
-}
-
-code {
- color: #fff;
- font-family: 'Fira Code', monospace;
- font-size: 0.9rem;
-}
-
-/* Plugins Section */
-.plugins {
- padding: 80px 0;
- background: var(--background-color);
- position: relative;
-}
-
-.plugins::before {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background:
- radial-gradient(circle at 100% 50%, rgba(71, 202, 255, 0.15), rgba(36, 36, 36, 0) 50%),
- radial-gradient(circle at 0% 50%, rgba(189, 52, 254, 0.15), rgba(36, 36, 36, 0) 50%);
- z-index: -1;
-}
-
-.plugins h2 {
- color: var(--text-color-dark);
-}
-
-.plugins-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
- gap: 2rem;
-}
-
-.plugin-card {
- background: var(--code-background);
- border: 1px solid rgba(255, 255, 255, 0.1);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
-}
-
-.plugin-card:hover {
- border-color: rgba(255, 255, 255, 0.2);
- box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
-}
-
-.plugin-card h3 {
- background: linear-gradient(120deg, #bd34fe 30%, #47caff);
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
-}
-
-.plugin-card p {
- color: var(--text-color-dark-2);
-}
-
-/* Footer */
-footer {
- background: var(--code-background);
- color: var(--text-color-dark);
- border-top: 1px solid rgba(255, 255, 255, 0.1);
-}
-
-footer::before {
- background:
- radial-gradient(circle at 0% 0%, rgba(189, 52, 254, 0.15), rgba(26, 26, 26, 0) 50%),
- radial-gradient(circle at 100% 100%, rgba(71, 202, 255, 0.15), rgba(26, 26, 26, 0) 50%);
-}
-
-.footer-content {
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 3rem;
-}
-
-.footer-section h4 {
- color: #bd34fe;
-}
-
-.footer-section a {
- color: var(--text-color-dark-2);
-}
-
-.footer-section a:hover {
- color: var(--text-color-dark);
-}
-
-/* Responsive Design */
-@media (max-width: 768px) {
- .nav-links {
- display: none;
- }
-
- .hero h1 {
- font-size: 2.5rem;
- }
-
- .hero .subtitle {
- font-size: 1.2rem;
- }
-
- .features-grid,
- .plugins-grid {
- grid-template-columns: 1fr;
- }
-
- .cta-buttons {
- flex-direction: column;
- }
-
- .footer-content {
- grid-template-columns: 1fr;
- text-align: center;
- }
-}
\ No newline at end of file