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