移除"从素材提取尾帧"功能(上游已实现类似功能)
- 移除 ExtractFrame handler 和路由 - 移除 AssetService 中的 ExtractFrameFromAsset 方法 - 移除 FFmpeg 中的 ExtractFrame 方法 - 移除前端 extractFrame API 和相关 UI Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -218,27 +218,3 @@ func (h *AssetHandler) ImportFromVideoGen(c *gin.Context) {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -181,7 +181,6 @@ func SetupRouter(cfg *config.Config, db *gorm.DB, log *logger.Logger, localStora
|
||||
assets.DELETE("/:id", assetHandler.DeleteAsset)
|
||||
assets.POST("/import/image/:image_gen_id", assetHandler.ImportFromImageGen)
|
||||
assets.POST("/import/video/:video_gen_id", assetHandler.ImportFromVideoGen)
|
||||
assets.POST("/:id/extract-frame", assetHandler.ExtractFrame)
|
||||
}
|
||||
|
||||
storyboards := api.Group("/storyboards")
|
||||
|
||||
@@ -2,11 +2,8 @@ package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
models "github.com/drama-generator/backend/domain/models"
|
||||
"github.com/drama-generator/backend/infrastructure/external/ffmpeg"
|
||||
@@ -326,120 +323,3 @@ func (s *AssetService) ImportFromVideoGen(videoGenID uint) (*models.Asset, error
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
69
infrastructure/external/ffmpeg/ffmpeg.go
vendored
69
infrastructure/external/ffmpeg/ffmpeg.go
vendored
@@ -853,72 +853,3 @@ func (f *FFmpeg) generateSilence(outputPath string, duration float64) (string, e
|
||||
f.log.Infow("Silence audio generated successfully", "output", outputPath)
|
||||
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,5 @@ export const assetAPI = {
|
||||
|
||||
importFromVideo(videoGenId: number) {
|
||||
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,24 +239,6 @@
|
||||
}}</span>
|
||||
</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="section-label">
|
||||
@@ -1004,8 +986,6 @@ const generatingImage = ref(false)
|
||||
const generatedImages = ref<ImageGeneration[]>([])
|
||||
const isSwitchingFrameType = ref(false) // 标志位:是否正在切换帧类型
|
||||
const loadingImages = ref(false)
|
||||
const selectedAssetForExtract = ref<number | null>(null) // 选中的素材用于提取尾帧
|
||||
const extractingFrame = ref(false) // 是否正在提取尾帧
|
||||
let pollingTimer: any = null
|
||||
let pollingFrameType: FrameType | null = null // 记录正在轮询的帧类型
|
||||
|
||||
@@ -1722,35 +1702,6 @@ 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 deleteImage = async (imageId: number) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user