feat: generate media cover assets
This commit is contained in:
@@ -2,20 +2,26 @@ package jobs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"quyun/v2/app/jobs/args"
|
"quyun/v2/app/jobs/args"
|
||||||
|
"quyun/v2/database/fields"
|
||||||
"quyun/v2/database/models"
|
"quyun/v2/database/models"
|
||||||
"quyun/v2/pkg/consts"
|
"quyun/v2/pkg/consts"
|
||||||
"quyun/v2/providers/storage"
|
"quyun/v2/providers/storage"
|
||||||
|
|
||||||
"github.com/riverqueue/river"
|
"github.com/riverqueue/river"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"go.ipao.vip/gen/types"
|
||||||
"gorm.io/gorm"
|
"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")
|
log.Warn("ffmpeg not found, skipping real transcoding")
|
||||||
} else {
|
} else {
|
||||||
outputDir := filepath.Dir(inputFile)
|
outputDir := filepath.Dir(inputFile)
|
||||||
coverKey := asset.ObjectKey + ".jpg"
|
coverTempKey := asset.ObjectKey + ".jpg"
|
||||||
coverFile := filepath.Join(outputDir, filepath.Base(coverKey))
|
coverFile := filepath.Join(outputDir, filepath.Base(coverTempKey))
|
||||||
|
|
||||||
// 生成封面,作为后续管线的占位输出。
|
// 生成封面,作为后续管线的占位输出。
|
||||||
cmd := exec.CommandContext(
|
cmd := exec.CommandContext(
|
||||||
@@ -95,7 +101,11 @@ func (j *MediaProcessWorker) Work(ctx context.Context, job *river.Job[args.Media
|
|||||||
finalStatus = consts.MediaAssetStatusFailed
|
finalStatus = consts.MediaAssetStatusFailed
|
||||||
} else {
|
} else {
|
||||||
log.Infof("Generated cover: %s", coverFile)
|
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 {
|
} else {
|
||||||
@@ -112,3 +122,118 @@ func (j *MediaProcessWorker) Work(ctx context.Context, job *river.Job[args.Media
|
|||||||
})
|
})
|
||||||
return err
|
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 处理 → 结果上传回对象存储 → 清理临时文件。
|
- Worker:从对象存储下载源文件到临时目录 → FFmpeg 处理 → 结果上传回对象存储 → 清理临时文件。
|
||||||
- 产物:封面/预览片段自动生成并回写 `media_assets`。
|
- 产物:封面/预览片段自动生成并回写 `media_assets`。
|
||||||
- 本地 FS 仍保留兼容路径(开发/测试使用)。
|
- 本地 FS 仍保留兼容路径(开发/测试使用)。
|
||||||
|
- 进度:本地视频处理已可生成封面资产(ffmpeg 可用时)。
|
||||||
|
|
||||||
**测试方案**
|
**测试方案**
|
||||||
- 对象存储模式下上传视频触发处理,封面/预览可访问;任务异常可重试。
|
- 对象存储模式下上传视频触发处理,封面/预览可访问;任务异常可重试。
|
||||||
|
|||||||
Reference in New Issue
Block a user