feat: generate media cover assets

This commit is contained in:
2026-01-18 13:49:20 +08:00
parent 3728a921d2
commit 38d8706038
2 changed files with 129 additions and 3 deletions

View File

@@ -2,20 +2,26 @@ package jobs
import (
"context"
"crypto/md5"
"encoding/hex"
"errors"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"time"
"quyun/v2/app/jobs/args"
"quyun/v2/database/fields"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"quyun/v2/providers/storage"
"github.com/riverqueue/river"
log "github.com/sirupsen/logrus"
"go.ipao.vip/gen/types"
"gorm.io/gorm"
)
@@ -74,8 +80,8 @@ func (j *MediaProcessWorker) Work(ctx context.Context, job *river.Job[args.Media
log.Warn("ffmpeg not found, skipping real transcoding")
} else {
outputDir := filepath.Dir(inputFile)
coverKey := asset.ObjectKey + ".jpg"
coverFile := filepath.Join(outputDir, filepath.Base(coverKey))
coverTempKey := asset.ObjectKey + ".jpg"
coverFile := filepath.Join(outputDir, filepath.Base(coverTempKey))
// 生成封面,作为后续管线的占位输出。
cmd := exec.CommandContext(
@@ -95,7 +101,11 @@ func (j *MediaProcessWorker) Work(ctx context.Context, job *river.Job[args.Media
finalStatus = consts.MediaAssetStatusFailed
} else {
log.Infof("Generated cover: %s", coverFile)
// TODO: 生成封面资产或写入 meta由后续管线接管
// 生成封面资产记录,便于后台可追踪产物
if err := j.registerCoverAsset(ctx, asset, coverFile); err != nil {
log.Errorf("register cover failed: %s", err)
finalStatus = consts.MediaAssetStatusFailed
}
}
}
} else {
@@ -112,3 +122,118 @@ func (j *MediaProcessWorker) Work(ctx context.Context, job *river.Job[args.Media
})
return err
}
func (j *MediaProcessWorker) registerCoverAsset(ctx context.Context, asset *models.MediaAsset, coverFile string) error {
if asset == nil || coverFile == "" {
return nil
}
if _, err := os.Stat(coverFile); err != nil {
return err
}
// 已存在封面派生资产时直接跳过。
tbl, q := models.MediaAssetQuery.QueryContext(ctx)
existing, err := q.Where(
tbl.SourceAssetID.Eq(asset.ID),
tbl.Type.Eq(consts.MediaAssetTypeImage),
).First()
if err == nil && existing != nil {
return nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
hash, size, err := fileMD5(coverFile)
if err != nil {
return err
}
var tenant *models.Tenant
if asset.TenantID > 0 {
tenant, err = models.TenantQuery.WithContext(ctx).
Where(models.TenantQuery.ID.Eq(asset.TenantID)).
First()
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
filename := asset.Meta.Data().Filename
if filename == "" {
filename = filepath.Base(asset.ObjectKey)
}
coverName := coverFilename(filename)
objectKey := buildObjectKey(tenant, hash, coverName)
// 本地存储将文件移动到目标 objectKey 位置,保持路径规范。
localPath := j.storage.Config.LocalPath
if localPath == "" {
localPath = "./storage"
}
dstPath := filepath.Join(localPath, filepath.FromSlash(objectKey))
if coverFile != dstPath {
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
if _, err := os.Stat(dstPath); err == nil {
_ = os.Remove(coverFile)
} else if err := os.Rename(coverFile, dstPath); err != nil {
return err
}
}
coverAsset := &models.MediaAsset{
TenantID: asset.TenantID,
UserID: asset.UserID,
Type: consts.MediaAssetTypeImage,
Status: consts.MediaAssetStatusReady,
Provider: asset.Provider,
Bucket: asset.Bucket,
ObjectKey: objectKey,
Hash: hash,
Variant: consts.MediaAssetVariantMain,
SourceAssetID: asset.ID,
Meta: types.NewJSONType(fields.MediaAssetMeta{
Filename: coverName,
Size: size,
}),
}
if err := models.MediaAssetQuery.WithContext(ctx).Create(coverAsset); err != nil {
return err
}
return nil
}
func coverFilename(filename string) string {
base := strings.TrimSuffix(filename, filepath.Ext(filename))
if base == "" {
base = "cover"
}
return base + "_cover.jpg"
}
func buildObjectKey(tenant *models.Tenant, hash, filename string) string {
// 按租户维度组织对象路径quyun/<tenant_uuid>/<hash>.<ext>
tenantUUID := "public"
if tenant != nil && tenant.UUID.String() != "" {
tenantUUID = tenant.UUID.String()
}
ext := strings.ToLower(filepath.Ext(filename))
return path.Join("quyun", tenantUUID, hash+ext)
}
func fileMD5(filename string) (string, int64, error) {
f, err := os.Open(filename)
if err != nil {
return "", 0, err
}
defer f.Close()
h := md5.New()
size, err := io.Copy(h, f)
if err != nil {
return "", size, err
}
return hex.EncodeToString(h.Sum(nil)), size, nil
}

View File

@@ -268,6 +268,7 @@
- Worker从对象存储下载源文件到临时目录 → FFmpeg 处理 → 结果上传回对象存储 → 清理临时文件。
- 产物:封面/预览片段自动生成并回写 `media_assets`
- 本地 FS 仍保留兼容路径(开发/测试使用)。
- 进度本地视频处理已可生成封面资产ffmpeg 可用时)。
**测试方案**
- 对象存储模式下上传视频触发处理,封面/预览可访问;任务异常可重试。