添加视频帧提取功能和阿里云OSS存储支持
- 新增从视频素材提取首帧/尾帧的功能,支持画面连续性编辑 - 添加阿里云OSS存储支持,可配置本地或OSS存储方式 - 导入视频素材时自动探测并更新视频时长信息 - 前端添加从素材提取尾帧的UI界面 - 添加FramePrompt模型的数据库迁移 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,7 @@ type AssetHandler struct {
|
|||||||
|
|
||||||
func NewAssetHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *AssetHandler {
|
func NewAssetHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *AssetHandler {
|
||||||
return &AssetHandler{
|
return &AssetHandler{
|
||||||
assetService: services.NewAssetService(db, log),
|
assetService: services.NewAssetService(db, log, cfg),
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,3 +218,27 @@ func (h *AssetHandler) ImportFromVideoGen(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, asset)
|
response.Success(c, asset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExtractFrame 从视频素材中提取帧
|
||||||
|
func (h *AssetHandler) ExtractFrame(c *gin.Context) {
|
||||||
|
assetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "无效的ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req services.ExtractFrameRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imageGen, err := h.assetService.ExtractFrameFromAsset(uint(assetID), &req)
|
||||||
|
if err != nil {
|
||||||
|
h.log.Errorw("Failed to extract frame", "error", err, "asset_id", assetID)
|
||||||
|
response.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, imageGen)
|
||||||
|
}
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ func SetupRouter(cfg *config.Config, db *gorm.DB, log *logger.Logger, localStora
|
|||||||
assets.DELETE("/:id", assetHandler.DeleteAsset)
|
assets.DELETE("/:id", assetHandler.DeleteAsset)
|
||||||
assets.POST("/import/image/:image_gen_id", assetHandler.ImportFromImageGen)
|
assets.POST("/import/image/:image_gen_id", assetHandler.ImportFromImageGen)
|
||||||
assets.POST("/import/video/:video_gen_id", assetHandler.ImportFromVideoGen)
|
assets.POST("/import/video/:video_gen_id", assetHandler.ImportFromVideoGen)
|
||||||
|
assets.POST("/:id/extract-frame", assetHandler.ExtractFrame)
|
||||||
}
|
}
|
||||||
|
|
||||||
storyboards := api.Group("/storyboards")
|
storyboards := api.Group("/storyboards")
|
||||||
|
|||||||
@@ -2,27 +2,48 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
models "github.com/drama-generator/backend/domain/models"
|
models "github.com/drama-generator/backend/domain/models"
|
||||||
"github.com/drama-generator/backend/infrastructure/external/ffmpeg"
|
"github.com/drama-generator/backend/infrastructure/external/ffmpeg"
|
||||||
|
"github.com/drama-generator/backend/infrastructure/storage"
|
||||||
|
"github.com/drama-generator/backend/pkg/config"
|
||||||
"github.com/drama-generator/backend/pkg/logger"
|
"github.com/drama-generator/backend/pkg/logger"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AssetService struct {
|
type AssetService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
ffmpeg *ffmpeg.FFmpeg
|
ffmpeg *ffmpeg.FFmpeg
|
||||||
|
cfg *config.Config
|
||||||
|
ossStorage *storage.OssStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAssetService(db *gorm.DB, log *logger.Logger) *AssetService {
|
func NewAssetService(db *gorm.DB, log *logger.Logger, cfg ...*config.Config) *AssetService {
|
||||||
return &AssetService{
|
service := &AssetService{
|
||||||
db: db,
|
db: db,
|
||||||
log: log,
|
log: log,
|
||||||
ffmpeg: ffmpeg.NewFFmpeg(log),
|
ffmpeg: ffmpeg.NewFFmpeg(log),
|
||||||
}
|
}
|
||||||
|
if len(cfg) > 0 {
|
||||||
|
service.cfg = cfg[0]
|
||||||
|
// 如果配置了 OSS,初始化 OSS 存储
|
||||||
|
if cfg[0].Storage.Type == "oss" && storage.IsOssConfigured(&cfg[0].Storage.Oss) {
|
||||||
|
ossStorage, err := storage.NewOssStorage(&cfg[0].Storage.Oss)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnw("Failed to initialize OSS storage, falling back to local", "error", err)
|
||||||
|
} else {
|
||||||
|
service.ossStorage = ossStorage
|
||||||
|
log.Infow("OSS storage initialized", "bucket", cfg[0].Storage.Oss.BucketName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateAssetRequest struct {
|
type CreateAssetRequest struct {
|
||||||
@@ -264,6 +285,23 @@ func (s *AssetService) ImportFromVideoGen(videoGenID uint) (*models.Asset, error
|
|||||||
storyboardNum = &videoGen.Storyboard.StoryboardNumber
|
storyboardNum = &videoGen.Storyboard.StoryboardNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果 duration 为空,尝试使用 FFmpeg 探测
|
||||||
|
duration := videoGen.Duration
|
||||||
|
if duration == nil || *duration == 0 {
|
||||||
|
s.log.Infow("Duration is empty, probing video duration", "video_gen_id", videoGenID)
|
||||||
|
probedDuration, err := s.ffmpeg.GetVideoDuration(*videoGen.VideoURL)
|
||||||
|
if err == nil && probedDuration > 0 {
|
||||||
|
durationInt := int(probedDuration + 0.5) // 四舍五入
|
||||||
|
duration = &durationInt
|
||||||
|
s.log.Infow("Probed video duration", "video_gen_id", videoGenID, "duration", durationInt)
|
||||||
|
|
||||||
|
// 同时更新 VideoGeneration 表
|
||||||
|
s.db.Model(&videoGen).Update("duration", durationInt)
|
||||||
|
} else {
|
||||||
|
s.log.Warnw("Failed to probe video duration", "video_gen_id", videoGenID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
asset := &models.Asset{
|
asset := &models.Asset{
|
||||||
Name: fmt.Sprintf("Video_%d", videoGen.ID),
|
Name: fmt.Sprintf("Video_%d", videoGen.ID),
|
||||||
Type: models.AssetTypeVideo,
|
Type: models.AssetTypeVideo,
|
||||||
@@ -273,7 +311,7 @@ func (s *AssetService) ImportFromVideoGen(videoGenID uint) (*models.Asset, error
|
|||||||
StoryboardID: videoGen.StoryboardID,
|
StoryboardID: videoGen.StoryboardID,
|
||||||
StoryboardNum: storyboardNum,
|
StoryboardNum: storyboardNum,
|
||||||
VideoGenID: &videoGenID,
|
VideoGenID: &videoGenID,
|
||||||
Duration: videoGen.Duration,
|
Duration: duration,
|
||||||
Width: videoGen.Width,
|
Width: videoGen.Width,
|
||||||
Height: videoGen.Height,
|
Height: videoGen.Height,
|
||||||
}
|
}
|
||||||
@@ -283,8 +321,125 @@ func (s *AssetService) ImportFromVideoGen(videoGenID uint) (*models.Asset, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.Create(asset).Error; err != nil {
|
if err := s.db.Create(asset).Error; err != nil {
|
||||||
return nil, fmt.Errorf("failed to create asset: %w", err)
|
return nil, fmt.Errorf("failed to create asset: %w\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return asset, nil
|
return asset, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExtractFrameRequest 视频帧提取请求
|
||||||
|
type ExtractFrameRequest struct {
|
||||||
|
Position string `json:"position"` // "first" 或 "last"
|
||||||
|
StoryboardID uint `json:"storyboard_id"` // 关联的分镜 ID
|
||||||
|
FrameType string `json:"frame_type"` // image_generations 的帧类型,默认 "first"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractFrameFromAsset 从视频素材中提取帧并保存到 image_generations 表
|
||||||
|
func (s *AssetService) ExtractFrameFromAsset(assetID uint, req *ExtractFrameRequest) (*models.ImageGeneration, error) {
|
||||||
|
// 获取素材
|
||||||
|
var asset models.Asset
|
||||||
|
if err := s.db.First(&asset, assetID).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("asset not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证是否为视频素材
|
||||||
|
if asset.Type != models.AssetTypeVideo {
|
||||||
|
return nil, fmt.Errorf("asset is not a video")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认值
|
||||||
|
position := req.Position
|
||||||
|
if position == "" {
|
||||||
|
position = "last"
|
||||||
|
}
|
||||||
|
frameType := req.FrameType
|
||||||
|
if frameType == "" {
|
||||||
|
frameType = "first" // 提取的尾帧用作下一个分镜的首帧
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成唯一文件名
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
fileName := fmt.Sprintf("frame_%s_%d_%s.jpg", position, assetID, timestamp)
|
||||||
|
|
||||||
|
// 创建临时目录
|
||||||
|
tmpDir := "./data/tmp"
|
||||||
|
if err := os.MkdirAll(tmpDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create temp directory: %w", err)
|
||||||
|
}
|
||||||
|
outputPath := filepath.Join(tmpDir, fileName)
|
||||||
|
|
||||||
|
// 使用 FFmpeg 提取帧
|
||||||
|
_, err := s.ffmpeg.ExtractFrame(asset.URL, outputPath, position)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to extract frame: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageURL string
|
||||||
|
|
||||||
|
// 根据存储类型上传
|
||||||
|
if s.ossStorage != nil {
|
||||||
|
// 上传到 OSS
|
||||||
|
url, err := s.ossStorage.UploadWithFilename(outputPath, "extracted_frames", fileName)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Errorw("Failed to upload to OSS, falling back to local", "error", err)
|
||||||
|
} else {
|
||||||
|
imageURL = url
|
||||||
|
s.log.Infow("Frame uploaded to OSS", "url", url)
|
||||||
|
// 删除临时文件
|
||||||
|
os.Remove(outputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 OSS 上传失败或未配置,使用本地存储
|
||||||
|
if imageURL == "" {
|
||||||
|
localPath := "./data/storage"
|
||||||
|
baseURL := "/static"
|
||||||
|
if s.cfg != nil && s.cfg.Storage.LocalPath != "" {
|
||||||
|
localPath = s.cfg.Storage.LocalPath
|
||||||
|
}
|
||||||
|
if s.cfg != nil && s.cfg.Storage.BaseURL != "" {
|
||||||
|
baseURL = s.cfg.Storage.BaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动文件到最终位置
|
||||||
|
finalDir := filepath.Join(localPath, "extracted_frames")
|
||||||
|
if err := os.MkdirAll(finalDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create output directory: %w", err)
|
||||||
|
}
|
||||||
|
finalPath := filepath.Join(finalDir, fileName)
|
||||||
|
if err := os.Rename(outputPath, finalPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to move file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
imageURL = fmt.Sprintf("%s/extracted_frames/%s", baseURL, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 DramaID
|
||||||
|
var dramaID uint
|
||||||
|
if asset.DramaID != nil {
|
||||||
|
dramaID = *asset.DramaID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 image_generation 记录
|
||||||
|
imageGen := &models.ImageGeneration{
|
||||||
|
DramaID: dramaID,
|
||||||
|
StoryboardID: &req.StoryboardID,
|
||||||
|
Prompt: fmt.Sprintf("Extracted %s frame from video asset #%d", position, assetID),
|
||||||
|
Status: models.ImageStatusCompleted,
|
||||||
|
ImageURL: &imageURL,
|
||||||
|
FrameType: &frameType,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(imageGen).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create image generation record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Infow("Frame extracted and saved",
|
||||||
|
"asset_id", assetID,
|
||||||
|
"position", position,
|
||||||
|
"image_gen_id", imageGen.ID,
|
||||||
|
"frame_type", frameType,
|
||||||
|
"image_url", imageURL)
|
||||||
|
|
||||||
|
return imageGen, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,9 +19,17 @@ database:
|
|||||||
max_open: 100
|
max_open: 100
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
type: "local"
|
type: "local" # 存储类型:local(本地)或 oss(阿里云OSS)
|
||||||
|
# 本地存储配置
|
||||||
local_path: "./data/storage"
|
local_path: "./data/storage"
|
||||||
base_url: "http://localhost:5678/static"
|
base_url: "http://localhost:5678/static"
|
||||||
|
# 阿里云 OSS 配置(type 为 oss 时生效)
|
||||||
|
oss:
|
||||||
|
endpoint: "oss-cn-hangzhou.aliyuncs.com" # OSS 服务地址
|
||||||
|
access_key_id: "" # AccessKey ID
|
||||||
|
access_key_secret: "" # AccessKey Secret
|
||||||
|
bucket_name: "" # Bucket 名称
|
||||||
|
custom_domain: "" # 自定义域名(可选,用于 CDN 加速)
|
||||||
|
|
||||||
ai:
|
ai:
|
||||||
default_text_provider: "openai"
|
default_text_provider: "openai"
|
||||||
|
|||||||
BIN
data/extracted_frames/9/frame_last_9.jpg
Normal file
BIN
data/extracted_frames/9/frame_last_9.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
2
go.mod
2
go.mod
@@ -16,6 +16,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
@@ -61,6 +62,7 @@ require (
|
|||||||
golang.org/x/net v0.38.0 // indirect
|
golang.org/x/net v0.38.0 // indirect
|
||||||
golang.org/x/sys v0.34.0 // indirect
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
golang.org/x/text v0.26.0 // indirect
|
golang.org/x/text v0.26.0 // indirect
|
||||||
|
golang.org/x/time v0.3.0 // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -38,6 +38,8 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
|
|||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
|
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
|
||||||
|
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
|
||||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||||
@@ -431,6 +433,8 @@ golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
|||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||||
|
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.ImageGeneration{},
|
&models.ImageGeneration{},
|
||||||
&models.VideoGeneration{},
|
&models.VideoGeneration{},
|
||||||
&models.VideoMerge{},
|
&models.VideoMerge{},
|
||||||
|
&models.FramePrompt{},
|
||||||
|
|
||||||
// AI配置
|
// AI配置
|
||||||
&models.AIServiceConfig{},
|
&models.AIServiceConfig{},
|
||||||
|
|||||||
69
infrastructure/external/ffmpeg/ffmpeg.go
vendored
69
infrastructure/external/ffmpeg/ffmpeg.go
vendored
@@ -853,3 +853,72 @@ func (f *FFmpeg) generateSilence(outputPath string, duration float64) (string, e
|
|||||||
f.log.Infow("Silence audio generated successfully", "output", outputPath)
|
f.log.Infow("Silence audio generated successfully", "output", outputPath)
|
||||||
return outputPath, nil
|
return outputPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExtractFrame 从视频中提取指定位置的帧
|
||||||
|
// position: "first" 提取首帧, "last" 提取尾帧
|
||||||
|
// 返回提取的图片文件路径
|
||||||
|
func (f *FFmpeg) ExtractFrame(videoURL, outputPath, position string) (string, error) {
|
||||||
|
f.log.Infow("Extracting frame from video", "url", videoURL, "position", position, "output", outputPath)
|
||||||
|
|
||||||
|
// 下载视频文件
|
||||||
|
downloadPath := filepath.Join(f.tempDir, fmt.Sprintf("video_%d.mp4", time.Now().Unix()))
|
||||||
|
localVideoPath, err := f.downloadVideo(videoURL, downloadPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to download video: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(localVideoPath)
|
||||||
|
|
||||||
|
// 确保输出目录存在
|
||||||
|
outputDir := filepath.Dir(outputPath)
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create output directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
|
||||||
|
if position == "last" {
|
||||||
|
// 提取尾帧:先获取视频时长,然后提取最后一帧
|
||||||
|
duration, err := f.GetVideoDuration(localVideoPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get video duration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取最后一帧(时长减去0.1秒的位置)
|
||||||
|
seekTime := duration - 0.1
|
||||||
|
if seekTime < 0 {
|
||||||
|
seekTime = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("ffmpeg",
|
||||||
|
"-ss", fmt.Sprintf("%.2f", seekTime),
|
||||||
|
"-i", localVideoPath,
|
||||||
|
"-vframes", "1",
|
||||||
|
"-q:v", "2",
|
||||||
|
"-y",
|
||||||
|
outputPath,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// 默认提取首帧
|
||||||
|
cmd = exec.Command("ffmpeg",
|
||||||
|
"-i", localVideoPath,
|
||||||
|
"-vframes", "1",
|
||||||
|
"-q:v", "2",
|
||||||
|
"-y",
|
||||||
|
outputPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
f.log.Errorw("FFmpeg frame extraction failed", "error", err, "output", string(output))
|
||||||
|
return "", fmt.Errorf("ffmpeg frame extraction failed: %w, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查输出文件是否存在
|
||||||
|
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("frame extraction failed: output file not created")
|
||||||
|
}
|
||||||
|
|
||||||
|
f.log.Infow("Frame extracted successfully", "output", outputPath, "position", position)
|
||||||
|
return outputPath, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,9 +43,18 @@ type DatabaseConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
Type string `mapstructure:"type"` // local, minio
|
Type string `mapstructure:"type"` // local, oss
|
||||||
LocalPath string `mapstructure:"local_path"` // 本地存储路径
|
LocalPath string `mapstructure:"local_path"` // 本地存储路径
|
||||||
BaseURL string `mapstructure:"base_url"` // 访问URL前缀
|
BaseURL string `mapstructure:"base_url"` // 访问URL前缀
|
||||||
|
Oss OssConfig `mapstructure:"oss"` // 阿里云 OSS 配置
|
||||||
|
}
|
||||||
|
|
||||||
|
type OssConfig struct {
|
||||||
|
Endpoint string `mapstructure:"endpoint"` // OSS 服务地址
|
||||||
|
AccessKeyID string `mapstructure:"access_key_id"` // AccessKey ID
|
||||||
|
AccessKeySecret string `mapstructure:"access_key_secret"` // AccessKey Secret
|
||||||
|
BucketName string `mapstructure:"bucket_name"` // Bucket 名称
|
||||||
|
CustomDomain string `mapstructure:"custom_domain"` // 自定义域名(可选)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIConfig struct {
|
type AIConfig struct {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
Asset,
|
Asset,
|
||||||
AssetCollection,
|
AssetCollection,
|
||||||
AssetTag,
|
AssetTag,
|
||||||
CreateAssetRequest,
|
CreateAssetRequest,
|
||||||
ListAssetsParams,
|
ListAssetsParams,
|
||||||
UpdateAssetRequest
|
UpdateAssetRequest
|
||||||
} from '../types/asset'
|
} from '../types/asset'
|
||||||
import request from '../utils/request'
|
import request from '../utils/request'
|
||||||
|
|
||||||
@@ -43,5 +43,9 @@ export const assetAPI = {
|
|||||||
|
|
||||||
importFromVideo(videoGenId: number) {
|
importFromVideo(videoGenId: number) {
|
||||||
return request.post<Asset>(`/assets/import/video/${videoGenId}`)
|
return request.post<Asset>(`/assets/import/video/${videoGenId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
extractFrame(assetId: number, data: { position: string; storyboard_id: number; frame_type?: string }) {
|
||||||
|
return request.post(`/assets/${assetId}/extract-frame`, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,6 +239,24 @@
|
|||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 从素材提取尾帧 -->
|
||||||
|
<div class="extract-frame-section" v-if="selectedFrameType === 'first'" style="margin: 12px 0; padding: 12px; background: #f5f7fa; border-radius: 8px;">
|
||||||
|
<div class="section-label" style="margin-bottom: 8px;">从素材提取尾帧(用于画面连续性)</div>
|
||||||
|
<div style="display: flex; gap: 10px; align-items: center;">
|
||||||
|
<el-select v-model="selectedAssetForExtract" placeholder="选择视频素材" style="width: 300px;" size="small">
|
||||||
|
<el-option
|
||||||
|
v-for="asset in videoAssets"
|
||||||
|
:key="asset.id"
|
||||||
|
:label="`镜头 #${asset.storyboard_num || asset.id} - ${asset.name}`"
|
||||||
|
:value="asset.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" size="small" :loading="extractingFrame" :disabled="!selectedAssetForExtract" @click="extractLastFrame">
|
||||||
|
提取尾帧
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 提示词区域 -->
|
<!-- 提示词区域 -->
|
||||||
<div class="prompt-section">
|
<div class="prompt-section">
|
||||||
<div class="section-label">
|
<div class="section-label">
|
||||||
@@ -952,6 +970,8 @@ const generatingImage = ref(false)
|
|||||||
const generatedImages = ref<ImageGeneration[]>([])
|
const generatedImages = ref<ImageGeneration[]>([])
|
||||||
const isSwitchingFrameType = ref(false) // 标志位:是否正在切换帧类型
|
const isSwitchingFrameType = ref(false) // 标志位:是否正在切换帧类型
|
||||||
const loadingImages = ref(false)
|
const loadingImages = ref(false)
|
||||||
|
const selectedAssetForExtract = ref<number | null>(null) // 选中的素材用于提取尾帧
|
||||||
|
const extractingFrame = ref(false) // 是否正在提取尾帧
|
||||||
let pollingTimer: any = null
|
let pollingTimer: any = null
|
||||||
let pollingFrameType: FrameType | null = null // 记录正在轮询的帧类型
|
let pollingFrameType: FrameType | null = null // 记录正在轮询的帧类型
|
||||||
|
|
||||||
@@ -1603,6 +1623,35 @@ const generateFrameImage = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从素材提取尾帧
|
||||||
|
const extractLastFrame = async () => {
|
||||||
|
if (!selectedAssetForExtract.value || !currentStoryboard.value) {
|
||||||
|
ElMessage.warning('请先选择视频素材')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
extractingFrame.value = true
|
||||||
|
try {
|
||||||
|
const result = await assetAPI.extractFrame(selectedAssetForExtract.value, {
|
||||||
|
position: 'last',
|
||||||
|
storyboard_id: currentStoryboard.value.id,
|
||||||
|
frame_type: 'first' // 提取的尾帧用作当前分镜的首帧
|
||||||
|
})
|
||||||
|
|
||||||
|
ElMessage.success('尾帧提取成功,已添加到首帧图片列表')
|
||||||
|
|
||||||
|
// 刷新图片列表
|
||||||
|
await loadStoryboardImages(currentStoryboard.value!.id, 'first')
|
||||||
|
|
||||||
|
// 清空选择
|
||||||
|
selectedAssetForExtract.value = null
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error('提取失败: ' + (error.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
extractingFrame.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取状态标签类型
|
// 获取状态标签类型
|
||||||
const getStatusType = (status: string) => {
|
const getStatusType = (status: string) => {
|
||||||
const statusMap: Record<string, any> = {
|
const statusMap: Record<string, any> = {
|
||||||
@@ -2154,7 +2203,13 @@ const handleTimelineSelect = (sceneId: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddStoryboard = async () => {
|
const handleAddStoryboard = async () => {
|
||||||
ElMessage.info('添加分镜功能开发中')
|
if (!currentStoryboard.value) {
|
||||||
|
ElMessage.warning('请先选择分镜')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用现有的 generateVideo 函数为当前分镜生成视频
|
||||||
|
await generateVideo()
|
||||||
}
|
}
|
||||||
|
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user