This commit is contained in:
2026-07-13 09:35:11 +08:00
parent 69c2dc4a61
commit 8002dd0912
4 changed files with 232 additions and 26 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -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
)
+4
View File
@@ -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=
+226 -25
View File
@@ -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
}