From 8002dd0912e8988816cbb6cdec867861a6d4e5f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Mon, 13 Jul 2026 09:35:11 +0800 Subject: [PATCH] 1 --- Dockerfile | 2 +- go.mod | 1 + go.sum | 4 + shortdrama/service/drama_service.go | 251 +++++++++++++++++++++++++--- 4 files changed, 232 insertions(+), 26 deletions(-) diff --git a/Dockerfile b/Dockerfile index caf83b3..561f976 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN go build -ldflags="-s -w" -o main ./main.go FROM alpine:3.19 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ - && apk add --no-cache ca-certificates tzdata ffmpeg + && apk add --no-cache ca-certificates tzdata ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone WORKDIR /app diff --git a/go.mod b/go.mod index 50695dd..32af510 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module video-factory go 1.26.1 require ( + github.com/Eyevinn/mp4ff v0.53.0 github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 github.com/gogf/gf/v2 v2.10.2 ) diff --git a/go.sum b/go.sum index 57341e6..56dc234 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Eyevinn/mp4ff v0.53.0 h1:aK4OF9gFwjrqvHRf0znEMCUVupgRFR1LB8OVpw9z1RA= +github.com/Eyevinn/mp4ff v0.53.0/go.mod h1:AhC+bOI7GSZmzuN4zFY9U76qMedbHI+8BdQXWrC9+8U= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= @@ -25,6 +27,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= +github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 h1:KLS68SWS2W749x7e+eCCOO3UD2Sbw+bIbLEPR8o1FXw= diff --git a/shortdrama/service/drama_service.go b/shortdrama/service/drama_service.go index d693e40..4a59b55 100644 --- a/shortdrama/service/drama_service.go +++ b/shortdrama/service/drama_service.go @@ -9,7 +9,6 @@ import ( "math" "net/http" "os" - "os/exec" "path/filepath" "strings" "time" @@ -21,6 +20,7 @@ import ( "video-factory/shortdrama/model/dto" "video-factory/shortdrama/model/entity" + "github.com/Eyevinn/mp4ff/mp4" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gcache" @@ -1401,33 +1401,236 @@ func (s *dramaService) waitForSegmentVideo(ctx context.Context, d *entity.Drama, return fmt.Errorf("串行等待超时: 任务 %d 第%d段视频在 %d 次轮询后仍未就绪", task.Id, task.SegmentIdx+1, maxAttempts) } -func (s *dramaService) extractLastFrame(videoPath, outputPath string) error { - cmd := exec.Command("ffmpeg", "-y", "-sseof", "-1", "-i", videoPath, - "-frames:v", "1", "-q:v", "2", outputPath) - return cmd.Run() +func (s *dramaService) concatVideos(inputs []string, output string) error { + if len(inputs) < 2 { + return fmt.Errorf("需要至少2个输入文件才能合并") + } + + // 1. 读取所有 MP4 文件到内存 + var files []*mp4.File + for _, path := range inputs { + f, err := mp4.ReadMP4File(path) + if err != nil { + return fmt.Errorf("读取 %s 失败: %w", path, err) + } + files = append(files, f) + } + + // 2. 计算累积 mdat 负载大小和各文件原始基址 + cumPayload := make([]uint64, len(files)) + totalPayload := uint64(0) + origBase := make([]uint64, len(files)) + for i, f := range files { + cumPayload[i] = totalPayload + totalPayload += uint64(len(f.Mdat.Data)) + origBase[i] = f.Ftyp.Size() + f.Moov.Size() + f.Mdat.HeaderSize() + } + + // 3. 以第一个文件为蓝本构建输出结构 + outFile := mp4.NewFile() + outFile.Ftyp = files[0].Ftyp + outFile.Moov = files[0].Moov + + outFile.Mdat = &mp4.MdatBox{} + outFile.Mdat.SetData(make([]byte, totalPayload)) + pos := 0 + for _, f := range files { + copy(outFile.Mdat.Data[pos:], f.Mdat.Data) + pos += len(f.Mdat.Data) + } + + // 4. 按轨道类型初始化合并状态 + type mergeState struct { + stbl *mp4.StblBox + chunkBase uint32 // 当前已合并的 chunk 数量(stsc 偏移用) + } + + states := make(map[string]*mergeState) + for _, trak := range files[0].Moov.Traks { + hdlr := trak.Mdia.Hdlr.HandlerType + if _, ok := states[hdlr]; ok { + continue + } + stbl := trak.Mdia.Minf.Stbl + nChunks := chunkCount(stbl) + states[hdlr] = &mergeState{stbl: stbl, chunkBase: nChunks} + } + + // 5. 合并后续文件 + for fi := 1; fi < len(files); fi++ { + f := files[fi] + for _, srcTrak := range f.Moov.Traks { + hdlr := srcTrak.Mdia.Hdlr.HandlerType + st, ok := states[hdlr] + if !ok { + continue + } + srcStbl := srcTrak.Mdia.Minf.Stbl + nChunks := chunkCount(srcStbl) + + // stco/co64:偏移调整 = 第一个文件的基址差 + 累积 mdat 偏移 + adjust := int64(origBase[0]-origBase[fi]) + int64(cumPayload[fi]) + mergeChunkOffsets(st.stbl, srcStbl, adjust) + + // stsz:合并采样大小表 + if err := mergeSampleSizes(st.stbl.Stsz, srcStbl.Stsz); err != nil { + return err + } + + // stts:合并时域采样表 + mergeTimeToSample(st.stbl.Stts, srcStbl.Stts) + + // stsc:合并样块映射表,FirstChunk 需平移 + if srcStbl.Stsc != nil { + for i, e := range srcStbl.Stsc.Entries { + sid := srcStbl.Stsc.GetSampleDescriptionID(i + 1) + if err := st.stbl.Stsc.AddEntry(e.FirstChunk+st.chunkBase, e.SamplesPerChunk, sid); err != nil { + return fmt.Errorf("stsc 合并失败: %w", err) + } + } + } + + st.chunkBase += nChunks + } + } + + // 6. 修正所有 chunk 偏移(合并后 moov 大小变化) + combinedBase := outFile.Ftyp.Size() + outFile.Moov.Size() + outFile.Mdat.HeaderSize() + finalAdjust := int64(combinedBase - origBase[0]) + for _, st := range states { + adjustChunkOffsets(st.stbl, finalAdjust) + } + + // 7. 更新 mvhd 总时长 + totalDur := uint64(0) + for _, f := range files { + totalDur += f.Moov.Mvhd.Duration + } + outFile.Moov.Mvhd.Duration = totalDur + + // 更新每个 tkhd 时长 + for _, trak := range outFile.Moov.Traks { + hdlr := trak.Mdia.Hdlr.HandlerType + totalDur = 0 + for _, f := range files { + for _, t := range f.Moov.Traks { + if t.Mdia.Hdlr.HandlerType == hdlr { + totalDur += t.Tkhd.Duration + break + } + } + } + trak.Tkhd.Duration = totalDur + } + + // 8. 重建顶级 Children(确保 mdat 指向合并后的数据) + outFile.Children = []mp4.Box{outFile.Ftyp, outFile.Moov, outFile.Mdat} + + // 9. 写入输出文件 + return mp4.WriteToFile(outFile, output) } -func (s *dramaService) concatVideos(inputs []string, output string) error { - listPath := output + ".list" - f, err := os.Create(listPath) - if err != nil { - return err +// chunkCount 返回 stbl 中的 chunk 数量 +func chunkCount(stbl *mp4.StblBox) uint32 { + if stbl.Stco != nil { + return uint32(len(stbl.Stco.ChunkOffset)) } - for _, p := range inputs { - _, _ = f.WriteString(fmt.Sprintf("file '%s'\n", p)) + if stbl.Co64 != nil { + return uint32(len(stbl.Co64.ChunkOffset)) } - _ = f.Close() - defer os.Remove(listPath) + return 0 +} - cmd := exec.Command("ffmpeg", "-y", "-f", "concat", "-safe", "0", - "-i", listPath, "-c", "copy", output) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("ffmpeg 合并失败 (%v): %s", err, string(out)) +// mergeChunkOffsets 将 src 的 stco/co64 追加到 base,每个偏移加上 adjust +func mergeChunkOffsets(base, src *mp4.StblBox, adjust int64) { + if src.Stco != nil { + if base.Stco == nil { + base.Stco = &mp4.StcoBox{} + } + for _, off := range src.Stco.ChunkOffset { + base.Stco.ChunkOffset = append(base.Stco.ChunkOffset, uint32(int64(off)+adjust)) + } + } + if src.Co64 != nil { + if base.Co64 == nil { + base.Co64 = &mp4.Co64Box{} + } + for _, off := range src.Co64.ChunkOffset { + base.Co64.ChunkOffset = append(base.Co64.ChunkOffset, uint64(int64(off)+adjust)) + } + } +} + +// adjustChunkOffsets 对 stbl 中所有 stco/co64 偏移统一加上 adjust +func adjustChunkOffsets(stbl *mp4.StblBox, adjust int64) { + if stbl.Stco != nil { + for i := range stbl.Stco.ChunkOffset { + stbl.Stco.ChunkOffset[i] = uint32(int64(stbl.Stco.ChunkOffset[i]) + adjust) + } + } + if stbl.Co64 != nil { + for i := range stbl.Co64.ChunkOffset { + stbl.Co64.ChunkOffset[i] = uint64(int64(stbl.Co64.ChunkOffset[i]) + adjust) + } + } +} + +// mergeSampleSizes 合并 stsz 采样大小表 +func mergeSampleSizes(base, src *mp4.StszBox) error { + if src == nil { + return nil + } + if base.SampleUniformSize > 0 { + if src.SampleUniformSize == base.SampleUniformSize { + base.SampleNumber += src.SampleNumber + } else { + sizes := make([]uint32, base.SampleNumber) + for i := range sizes { + sizes[i] = base.SampleUniformSize + } + if src.SampleUniformSize > 0 { + for i := uint32(0); i < src.SampleNumber; i++ { + sizes = append(sizes, src.SampleUniformSize) + } + } else { + sizes = append(sizes, src.SampleSize...) + } + base.SampleUniformSize = 0 + base.SampleSize = sizes + base.SampleNumber = uint32(len(sizes)) + } + } else { + if src.SampleUniformSize > 0 { + for i := uint32(0); i < src.SampleNumber; i++ { + base.SampleSize = append(base.SampleSize, src.SampleUniformSize) + } + } else { + base.SampleSize = append(base.SampleSize, src.SampleSize...) + } + base.SampleNumber = uint32(len(base.SampleSize)) } return nil } +// mergeTimeToSample 合并 stts 时域采样表(相邻相同 delta 的条目合并) +func mergeTimeToSample(base, src *mp4.SttsBox) { + if src == nil || len(src.SampleCount) == 0 { + return + } + if len(base.SampleCount) > 0 { + lastDelta := base.SampleTimeDelta[len(base.SampleTimeDelta)-1] + firstDelta := src.SampleTimeDelta[0] + if lastDelta == firstDelta { + base.SampleCount[len(base.SampleCount)-1] += src.SampleCount[0] + base.SampleCount = append(base.SampleCount, src.SampleCount[1:]...) + base.SampleTimeDelta = append(base.SampleTimeDelta, src.SampleTimeDelta[1:]...) + return + } + } + base.SampleCount = append(base.SampleCount, src.SampleCount...) + base.SampleTimeDelta = append(base.SampleTimeDelta, src.SampleTimeDelta...) +} + func (s *dramaService) downloadFile(ctx context.Context, url, dest string) error { resp, err := http.Get(url) if err != nil { @@ -1466,14 +1669,12 @@ func (s *dramaService) downloadFile(ctx context.Context, url, dest string) error } func (s *dramaService) probeVideo(path string) error { - cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", - "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path) - out, err := cmd.CombinedOutput() + f, err := mp4.ReadMP4File(path) if err != nil { - return fmt.Errorf("ffprobe 检查失败 (%v): %s", err, string(out)) + return fmt.Errorf("视频文件无效: %w", err) } - if len(out) == 0 { - return fmt.Errorf("无法获取视频时长") + if f.Moov == nil { + return fmt.Errorf("视频文件缺少 moov box") } return nil }