初始化
This commit is contained in:
@@ -0,0 +1,735 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"juhe-factory/api/internal/billing"
|
||||
"juhe-factory/api/internal/model"
|
||||
queuepkg "juhe-factory/api/internal/queue"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var activeTaskStatuses = []string{"pending_submission", "submitting", "submitted", "processing", "result_ready", "downloading", "cancel_requested"}
|
||||
|
||||
func (s *Creative) InsertStoryboard(userID, projectID, storyboardID uuid.UUID, position string) (*model.EpisodeStoryboard, error) {
|
||||
if position != "before" && position != "after" {
|
||||
return nil, errors.New("插入位置无效")
|
||||
}
|
||||
created := &model.EpisodeStoryboard{}
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var target model.EpisodeStoryboard
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Table("episode_storyboards sb").Select("sb.*").
|
||||
Joins(`JOIN creative_projects p ON p.id=? AND p.user_id=? AND p.deleted_at IS NULL AND
|
||||
(sb.project_id=p.id OR EXISTS (SELECT 1 FROM project_episodes e WHERE e.id=sb.episode_id AND e.project_id=p.id AND e.deleted_at IS NULL))`, projectID, userID).
|
||||
Where("sb.id=? AND sb.deleted_at IS NULL", storyboardID).Take(&target).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
parentColumn, parentID := storyboardParent(target)
|
||||
sequence, startMS := target.SequenceNo, target.StartMS
|
||||
if position == "after" {
|
||||
sequence, startMS = target.SequenceNo+1, target.EndMS
|
||||
}
|
||||
if err := tx.Exec(`UPDATE episode_storyboards SET sequence_no=sequence_no+100000
|
||||
WHERE `+parentColumn+`=? AND sequence_no>=? AND deleted_at IS NULL`, parentID, sequence).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`UPDATE episode_storyboards SET sequence_no=sequence_no-99999,start_ms=start_ms+5000,end_ms=end_ms+5000
|
||||
WHERE `+parentColumn+`=? AND sequence_no>=? AND deleted_at IS NULL`, parentID, sequence+100000).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
created = &model.EpisodeStoryboard{
|
||||
EpisodeID: target.EpisodeID, ProjectID: target.ProjectID, SequenceNo: sequence, StableKey: "manual-" + uuid.NewString(),
|
||||
StartMS: startMS, EndMS: startMS + 5000, DurationSeconds: 5, Title: fmt.Sprintf("分镜 %d", sequence),
|
||||
Dialogue: json.RawMessage(`[]`), AssetRefs: json.RawMessage(`[]`), Status: "idle",
|
||||
}
|
||||
return tx.Create(created).Error
|
||||
})
|
||||
return created, err
|
||||
}
|
||||
|
||||
func (s *Creative) DeleteStoryboard(userID, projectID, storyboardID uuid.UUID) ([]string, error) {
|
||||
media := newDeletionMediaSet()
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var storyboard model.EpisodeStoryboard
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Table("episode_storyboards sb").Select("sb.*").
|
||||
Joins(`JOIN creative_projects p ON p.id=? AND p.user_id=? AND p.deleted_at IS NULL AND
|
||||
(sb.project_id=p.id OR EXISTS (SELECT 1 FROM project_episodes e WHERE e.id=sb.episode_id AND e.project_id=p.id AND e.deleted_at IS NULL))`, projectID, userID).
|
||||
Where("sb.id=? AND sb.deleted_at IS NULL", storyboardID).Take(&storyboard).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
parentColumn, parentID := storyboardParent(storyboard)
|
||||
var active int64
|
||||
if err := tx.Model(&model.GenerationTask{}).Where("storyboard_id=? AND status IN ?", storyboardID, activeTaskStatuses).Count(&active).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if active > 0 {
|
||||
return errors.New("当前分镜存在进行中的任务,暂时不能删除")
|
||||
}
|
||||
if err := media.addQuery(tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key").
|
||||
Joins("JOIN generation_outputs output ON output.media_asset_id=media.id").
|
||||
Joins("JOIN generation_tasks gt ON gt.id=output.task_id").
|
||||
Where("gt.storyboard_id=? AND (gt.task_type IN ('video_generation','prompt_reverse') OR (gt.task_type='image_generation' AND gt.input_data->>'target_type'='storyboard'))", storyboardID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if storyboard.ThumbnailAssetID != nil {
|
||||
if err := media.addQuery(tx.Table("media_assets").Select("id,object_key").Where("id=?", *storyboard.ThumbnailAssetID)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
storyboardMediaPattern := fmt.Sprintf("juyou_ran/video-redraw/projects/%s/storyboards/%%-%s/%%", projectID, storyboardID)
|
||||
if storyboard.EpisodeID != nil {
|
||||
storyboardMediaPattern = fmt.Sprintf("juyou_ran/video-redraw/projects/%s/episodes/%s/storyboards/%%-%s/%%", projectID, *storyboard.EpisodeID, storyboardID)
|
||||
}
|
||||
if err := collectMediaByObjectKey(tx, media, storyboardMediaPattern); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&storyboard).Update("active_output_id", nil).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`DELETE FROM generation_outputs WHERE task_id IN (
|
||||
SELECT id FROM generation_tasks WHERE storyboard_id=? AND (task_type IN ('video_generation','prompt_reverse')
|
||||
OR (task_type='image_generation' AND input_data->>'target_type'='storyboard')))`, storyboardID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`DELETE FROM generation_tasks WHERE storyboard_id=? AND (task_type IN ('video_generation','prompt_reverse')
|
||||
OR (task_type='image_generation' AND input_data->>'target_type'='storyboard'))`, storyboardID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.GenerationTask{}).Where("storyboard_id=?", storyboardID).Update("storyboard_id", nil).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM episode_storyboards WHERE id=?", storyboardID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`UPDATE episode_storyboards SET sequence_no=sequence_no+100000
|
||||
WHERE `+parentColumn+`=? AND sequence_no>? AND deleted_at IS NULL`, parentID, storyboard.SequenceNo).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
shiftMS := int64(storyboard.DurationSeconds * 1000)
|
||||
if err := tx.Exec(`UPDATE episode_storyboards SET sequence_no=sequence_no-100001,
|
||||
start_ms=GREATEST(0,start_ms-?),end_ms=GREATEST(1,end_ms-?)
|
||||
WHERE `+parentColumn+`=? AND sequence_no>? AND deleted_at IS NULL`, shiftMS, shiftMS, parentID, storyboard.SequenceNo+100000).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return media.deleteRows(tx)
|
||||
})
|
||||
return media.objectKeys(), err
|
||||
}
|
||||
|
||||
func storyboardParent(storyboard model.EpisodeStoryboard) (string, uuid.UUID) {
|
||||
if storyboard.ProjectID != nil {
|
||||
return "project_id", *storyboard.ProjectID
|
||||
}
|
||||
return "episode_id", *storyboard.EpisodeID
|
||||
}
|
||||
|
||||
func (s *Creative) DeleteEpisode(userID, projectID, episodeID uuid.UUID) ([]string, error) {
|
||||
media := newDeletionMediaSet()
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var episode model.ProjectEpisode
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Table("project_episodes e").Select("e.*").
|
||||
Joins("JOIN creative_projects p ON p.id=e.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID).
|
||||
Where("e.id=? AND e.project_id=? AND e.deleted_at IS NULL", episodeID, projectID).Take(&episode).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var active int64
|
||||
if err := tx.Model(&model.GenerationTask{}).Where("episode_id=? AND status IN ?", episodeID, activeTaskStatuses).Count(&active).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if active > 0 {
|
||||
return errors.New("剧集存在排队中或处理中的任务,暂时不能删除")
|
||||
}
|
||||
if err := tx.Model(&model.DramaParseTask{}).Where("episode_id=? AND status IN ?", episodeID, []string{"queued", "running", "retry_wait", "cancel_requested"}).Count(&active).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if active > 0 {
|
||||
return errors.New("剧集存在进行中的剧本解析任务,暂时不能删除")
|
||||
}
|
||||
if err := removeEpisodeDerivedContent(tx, projectID, episodeID, media); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := media.addQuery(tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key").
|
||||
Joins(`JOIN project_episodes episode ON media.id=episode.cover_asset_id
|
||||
OR media.id=episode.source_video_asset_id OR media.id=episode.subtitle_asset_id`).
|
||||
Where("episode.id=?", episodeID)); err != nil {
|
||||
return err
|
||||
}
|
||||
// 如果删除的是第一集,将项目封面切换到新的第一集封面。
|
||||
if episode.CoverAssetID != nil {
|
||||
var nextEpisode struct{ CoverAssetID *uuid.UUID }
|
||||
if err := tx.Table("project_episodes").Select("cover_asset_id").Where("project_id=? AND id<>? AND deleted_at IS NULL AND cover_asset_id IS NOT NULL", projectID, episodeID).
|
||||
Order("episode_no").Limit(1).Find(&nextEpisode).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.CreativeProject{}).Where("id=?", projectID).Update("cover_asset_id", nextEpisode.CoverAssetID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
episodeMediaPattern := fmt.Sprintf("juyou_ran/video-redraw/projects/%s/episodes/%%%s/%%", projectID, episodeID)
|
||||
if err := collectMediaByObjectKey(tx, media, episodeMediaPattern); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("UPDATE generation_tasks SET episode_id=NULL WHERE episode_id=?", episodeID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("UPDATE project_assets SET source_episode_id=NULL WHERE source_episode_id=?", episodeID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("UPDATE drama_parse_batches SET current_episode_id=NULL WHERE current_episode_id=?", episodeID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM project_episodes WHERE id=?", episodeID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return media.deleteRows(tx)
|
||||
})
|
||||
return media.objectKeys(), err
|
||||
}
|
||||
|
||||
// ResetEpisodeAnalysis permanently removes generated storyboards, prompts and their task history.
|
||||
// Project assets are intentionally left untouched so they can be reused for a new analysis.
|
||||
func (s *Creative) ResetEpisodeAnalysis(userID, projectID, episodeID uuid.UUID) ([]string, error) {
|
||||
media := newDeletionMediaSet()
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var episode model.ProjectEpisode
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Table("project_episodes e").Select("e.*").
|
||||
Joins("JOIN creative_projects p ON p.id=e.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID).
|
||||
Where("e.id=? AND e.project_id=? AND e.deleted_at IS NULL", episodeID, projectID).Take(&episode).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var active int64
|
||||
if err := tx.Model(&model.GenerationTask{}).Where("episode_id=? AND status IN ?", episodeID, activeTaskStatuses).Count(&active).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if active > 0 {
|
||||
return errors.New("当前剧集存在进行中的生成任务,暂时不能重新解析")
|
||||
}
|
||||
if err := tx.Model(&model.DramaParseTask{}).Where("episode_id=? AND status IN ?", episodeID, []string{"queued", "running", "retry_wait", "cancel_requested"}).Count(&active).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if active > 0 {
|
||||
return errors.New("当前剧集存在进行中的剧本解析任务,暂时不能重新解析")
|
||||
}
|
||||
if err := removeEpisodeDerivedContent(tx, projectID, episodeID, media); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := media.deleteRows(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&episode).Updates(map[string]any{"status": "uploaded", "analysis_message": nil, "redraw_script": nil}).Error
|
||||
})
|
||||
return media.objectKeys(), err
|
||||
}
|
||||
|
||||
func (s *Creative) ListTasks(userID, projectID uuid.UUID, episodeID *uuid.UUID) ([]map[string]any, error) {
|
||||
items := make([]map[string]any, 0)
|
||||
query := s.DB.Table("generation_tasks gt").
|
||||
Select(`gt.id,gt.request_id,gt.task_type,gt.status,gt.episode_id,gt.storyboard_id,gt.input_data->>'mode' AS mode,gt.input_data->>'phase_started_at' AS phase_started_at,gt.estimated_points::text AS estimated_points,
|
||||
gt.actual_points::text AS actual_points,gt.error_code,gt.error_message,gt.created_at,gt.submitted_at,gt.finished_at,
|
||||
coalesce(sb.sequence_no,(gt.input_data->>'current_sequence')::int,0) AS sequence_no,gt.input_data->>'asset_id' AS asset_id,media.public_url AS result_url,
|
||||
(SELECT max(progress.created_at) FROM generation_outputs progress WHERE progress.task_id=gt.id) AS last_result_at`).
|
||||
Joins("JOIN creative_projects p ON p.id=gt.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID).
|
||||
Joins("LEFT JOIN episode_storyboards sb ON sb.id=gt.storyboard_id").
|
||||
Joins("LEFT JOIN generation_outputs output ON output.task_id=gt.id AND output.sequence_no=1").
|
||||
Joins("LEFT JOIN media_assets media ON media.id=output.media_asset_id AND media.deleted_at IS NULL").
|
||||
Where("gt.project_id=?", projectID)
|
||||
if episodeID != nil {
|
||||
query = query.Where("gt.episode_id=?", *episodeID)
|
||||
}
|
||||
return items, query.Order("gt.created_at DESC").Limit(500).Find(&items).Error
|
||||
}
|
||||
|
||||
func (s *Creative) CancelTask(userID, projectID, taskID uuid.UUID) error {
|
||||
var channelID *uuid.UUID
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var task model.GenerationTask
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND project_id=? AND user_id=?", taskID, projectID, userID).First(&task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var projectType string
|
||||
if err := tx.Table("creative_projects").Where("id=?", projectID).Pluck("project_type", &projectType).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
channelID = task.ChannelID
|
||||
if task.TaskType == "prompt_reverse" && (task.Status == "pending_submission" || task.Status == "submitted" || task.Status == "processing" || task.Status == "cancel_requested") {
|
||||
return cancelPromptReverseTask(tx, &task)
|
||||
}
|
||||
switch task.Status {
|
||||
case "pending_submission":
|
||||
refunded := false
|
||||
chargeCancellation := task.TaskType == "video_generation" || projectType == "premium_drama"
|
||||
if !chargeCancellation {
|
||||
var err error
|
||||
refunded, err = billing.RefundGenerationTask(tx, &task, "视频生成失败返还")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
actual := "0.00"
|
||||
if chargeCancellation {
|
||||
actual = task.PrepaidPoints
|
||||
}
|
||||
updates := map[string]any{"status": "cancelled", "actual_points": actual, "finished_at": time.Now(), "error_code": nil, "error_message": "用户取消"}
|
||||
if refunded {
|
||||
updates["cost_refunded"] = true
|
||||
}
|
||||
if err := tx.Model(&task).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return resetRelatedStatus(tx, task)
|
||||
case "submitted", "processing":
|
||||
if task.TaskType != "prompt_reverse" && strings.TrimSpace(task.UpstreamTaskID) == "" {
|
||||
return errors.New("上游任务标识尚未就绪,请稍后重试取消")
|
||||
}
|
||||
return tx.Model(&task).Updates(map[string]any{"status": "cancel_requested", "error_code": nil, "error_message": "等待当前处理安全结束"}).Error
|
||||
case "submitting":
|
||||
return errors.New("任务正在提交,请稍后重试取消")
|
||||
case "cancel_requested":
|
||||
return nil
|
||||
case "result_ready", "downloading", "succeeded":
|
||||
return errors.New("生成已经完成,不能取消")
|
||||
case "failed", "cancelled":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("任务状态 %s 不允许取消", task.Status)
|
||||
}
|
||||
})
|
||||
if err == nil && channelID != nil && s.Queue != nil {
|
||||
_ = queuepkg.EnqueueID(s.Queue, queuepkg.TypeDispatchChannel, *channelID, 0)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func cancelPromptReverseTask(tx *gorm.DB, task *model.GenerationTask) error {
|
||||
var input struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
_ = json.Unmarshal(task.InputData, &input)
|
||||
updates := map[string]any{
|
||||
"status": "cancelled", "actual_points": task.PrepaidPoints, "finished_at": time.Now(),
|
||||
"error_code": nil, "error_message": "用户取消,已扣积分不退",
|
||||
}
|
||||
if err := tx.Model(task).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if task.StoryboardID != nil {
|
||||
return nil
|
||||
}
|
||||
if task.EpisodeID == nil && task.ProjectID != nil {
|
||||
if input.Mode == "script" {
|
||||
return tx.Model(&model.CreativeProject{}).Where("id=?", *task.ProjectID).
|
||||
Updates(map[string]any{"redraw_status": "review", "analysis_message": "剧本反推已取消"}).Error
|
||||
}
|
||||
var storyboardCount int64
|
||||
if err := tx.Model(&model.EpisodeStoryboard{}).Where("project_id=? AND deleted_at IS NULL", *task.ProjectID).Count(&storyboardCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
status := "uploaded"
|
||||
if storyboardCount > 0 {
|
||||
status = "review"
|
||||
}
|
||||
return tx.Model(&model.CreativeProject{}).Where("id=?", *task.ProjectID).Updates(map[string]any{"redraw_status": status, "analysis_message": "视频分析已取消"}).Error
|
||||
}
|
||||
if task.EpisodeID == nil {
|
||||
return nil
|
||||
}
|
||||
var storyboardCount int64
|
||||
if err := tx.Model(&model.EpisodeStoryboard{}).Where("episode_id=? AND deleted_at IS NULL", *task.EpisodeID).Count(&storyboardCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
status := "uploaded"
|
||||
if storyboardCount > 0 {
|
||||
status = "review"
|
||||
}
|
||||
return tx.Model(&model.ProjectEpisode{}).Where("id=?", *task.EpisodeID).Updates(map[string]any{"status": status, "analysis_message": "视频分析已取消"}).Error
|
||||
}
|
||||
|
||||
func (s *Creative) failQueuedTask(taskID uuid.UUID, code, message string) error {
|
||||
return s.failQueuedTasks([]uuid.UUID{taskID}, code, message)
|
||||
}
|
||||
|
||||
func (s *Creative) failQueuedTasks(taskIDs []uuid.UUID, code, message string) error {
|
||||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, taskID := range taskIDs {
|
||||
var task model.GenerationTask
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", taskID).First(&task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Status == "succeeded" || task.Status == "failed" || task.Status == "cancelled" {
|
||||
continue
|
||||
}
|
||||
refundRemark := map[string]string{"prompt_reverse": "视频反推失败返还", "script_analysis": "剧本分析失败返还", "image_generation": "图片生成失败返还", "video_generation": "视频生成失败返还"}[task.TaskType]
|
||||
var refunded bool
|
||||
var err error
|
||||
if task.TaskType == "prompt_reverse" || task.TaskType == "script_analysis" {
|
||||
refunded, err = billing.RefundTextGenerationTask(tx, &task, refundRemark)
|
||||
} else {
|
||||
refunded, err = billing.RefundGenerationTask(tx, &task, refundRemark)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates := map[string]any{
|
||||
"status": "failed", "actual_points": "0.00", "finished_at": time.Now(),
|
||||
"error_code": code, "error_message": message,
|
||||
}
|
||||
if refunded {
|
||||
updates["cost_refunded"] = true
|
||||
}
|
||||
if err := tx.Model(&task).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if task.TaskType == "video_generation" && task.StoryboardID != nil {
|
||||
if err := tx.Model(&model.EpisodeStoryboard{}).Where("id=?", *task.StoryboardID).Update("status", "idle").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if task.TaskType == "prompt_reverse" && task.StoryboardID == nil && task.EpisodeID != nil {
|
||||
var storyboardCount int64
|
||||
if err := tx.Model(&model.EpisodeStoryboard{}).Where("episode_id=? AND deleted_at IS NULL", *task.EpisodeID).Count(&storyboardCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
status := "uploaded"
|
||||
if storyboardCount > 0 {
|
||||
status = "review"
|
||||
}
|
||||
if err := tx.Model(&model.ProjectEpisode{}).Where("id=?", *task.EpisodeID).Updates(map[string]any{"status": status, "analysis_message": message}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if task.TaskType == "prompt_reverse" && task.StoryboardID == nil && task.ProjectID != nil {
|
||||
var storyboardCount int64
|
||||
if err := tx.Model(&model.EpisodeStoryboard{}).Where("project_id=? AND deleted_at IS NULL", *task.ProjectID).Count(&storyboardCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
status := "uploaded"
|
||||
if storyboardCount > 0 {
|
||||
status = "review"
|
||||
}
|
||||
if err := tx.Model(&model.CreativeProject{}).Where("id=?", *task.ProjectID).Updates(map[string]any{"redraw_status": status, "analysis_message": message}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func resetRelatedStatus(tx *gorm.DB, task model.GenerationTask) error {
|
||||
if task.TaskType == "video_generation" && task.StoryboardID != nil {
|
||||
var active int64
|
||||
if err := tx.Model(&model.GenerationTask{}).Where("storyboard_id=? AND task_type='video_generation' AND status IN ?", *task.StoryboardID, activeTaskStatuses).Count(&active).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if active == 0 {
|
||||
return tx.Model(&model.EpisodeStoryboard{}).Where("id=?", *task.StoryboardID).Update("status", "idle").Error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Creative) ListStoryboardOutputs(userID, projectID, storyboardID uuid.UUID) ([]map[string]any, error) {
|
||||
items := make([]map[string]any, 0)
|
||||
err := s.DB.Table("generation_outputs output").
|
||||
Select(`output.id,output.media_asset_id,output.output_type,output.sequence_no,output.metadata,output.created_at,output.task_id,
|
||||
media.public_url,media.mime_type,media.size_bytes,gt.estimated_points::text AS estimated_points,
|
||||
gt.actual_points::text AS actual_points,CASE WHEN gt.task_type='video_generation' THEN sb.active_output_id=output.id ELSE sb.thumbnail_asset_id=output.media_asset_id END AS active,
|
||||
CASE WHEN gt.task_type='video_generation' THEN sb.active_output_id=output.id OR coalesce(output.metadata->>'candidate','false')='true' ELSE false END AS candidate`).
|
||||
Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.storyboard_id=? AND (gt.task_type='video_generation' OR (gt.task_type='image_generation' AND gt.input_data->>'target_type'='storyboard'))", storyboardID).
|
||||
Joins("JOIN media_assets media ON media.id=output.media_asset_id AND media.deleted_at IS NULL").
|
||||
Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id AND sb.deleted_at IS NULL").
|
||||
Joins(`JOIN creative_projects p ON p.id=? AND p.user_id=? AND p.deleted_at IS NULL AND
|
||||
(sb.project_id=p.id OR EXISTS (SELECT 1 FROM project_episodes e WHERE e.id=sb.episode_id AND e.project_id=p.id AND e.deleted_at IS NULL))`, projectID, userID).
|
||||
Order("output.created_at DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// RemoveStoryboardImage 清空分镜主图引用,保留生成结果以便在历史记录中恢复。
|
||||
func (s *Creative) RemoveStoryboardImage(userID, projectID, storyboardID uuid.UUID) error {
|
||||
ownedStoryboard := `(project_id=? AND EXISTS (SELECT 1 FROM creative_projects p WHERE p.id=? AND p.user_id=? AND p.deleted_at IS NULL)) OR
|
||||
(episode_id IN (SELECT e.id FROM project_episodes e JOIN creative_projects p ON p.id=e.project_id
|
||||
WHERE e.project_id=? AND p.user_id=? AND e.deleted_at IS NULL AND p.deleted_at IS NULL))`
|
||||
result := s.DB.Model(&model.EpisodeStoryboard{}).
|
||||
Where("id=? AND thumbnail_asset_id IS NOT NULL AND deleted_at IS NULL AND ("+ownedStoryboard+")", storyboardID, projectID, projectID, userID, projectID, userID).
|
||||
Updates(map[string]any{"thumbnail_asset_id": nil, "status": "idle", "user_edited": true})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
var exists int64
|
||||
if err := s.DB.Model(&model.EpisodeStoryboard{}).
|
||||
Where("id=? AND deleted_at IS NULL AND ("+ownedStoryboard+")", storyboardID, projectID, projectID, userID, projectID, userID).
|
||||
Count(&exists).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if exists == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Creative) AddStoryboardCandidate(userID, projectID, storyboardID, outputID uuid.UUID) error {
|
||||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var row struct{ Candidate bool }
|
||||
if err := tx.Table("generation_outputs output").
|
||||
Select("sb.active_output_id=output.id OR coalesce(output.metadata->>'candidate','false')='true' AS candidate").
|
||||
Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.storyboard_id=? AND gt.task_type='video_generation'", storyboardID).
|
||||
Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id AND sb.deleted_at IS NULL").
|
||||
Joins("JOIN project_episodes e ON e.id=sb.episode_id AND e.deleted_at IS NULL").
|
||||
Joins("JOIN creative_projects p ON p.id=e.project_id AND p.id=? AND p.user_id=? AND p.project_type='premium_drama' AND p.deleted_at IS NULL", projectID, userID).
|
||||
Where("output.id=?", outputID).Take(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if row.Candidate {
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Table("generation_outputs output").
|
||||
Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.storyboard_id=? AND gt.task_type='video_generation'", storyboardID).
|
||||
Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id").
|
||||
Where("sb.active_output_id=output.id OR coalesce(output.metadata->>'candidate','false')='true'").Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= 3 {
|
||||
return errors.New("当前分镜最多保留 3 个备选视频,请先删除一个备选视频")
|
||||
}
|
||||
var activeTasks int64
|
||||
if err := tx.Model(&model.GenerationTask{}).
|
||||
Where("storyboard_id=? AND task_type='video_generation' AND status IN ?", storyboardID, activeTaskStatuses).
|
||||
Count(&activeTasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count+activeTasks >= 3 {
|
||||
return errors.New("生成中的视频已占用备选位置,请等待生成完成或先删除一个备选视频")
|
||||
}
|
||||
return tx.Model(&model.GenerationOutput{}).Where("id=?", outputID).
|
||||
Update("metadata", gorm.Expr("jsonb_set(coalesce(metadata,'{}'::jsonb),'{candidate}','true'::jsonb,true)")).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Creative) RemoveStoryboardCandidate(userID, projectID, storyboardID, outputID uuid.UUID) error {
|
||||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var row struct {
|
||||
Active bool
|
||||
Candidate bool
|
||||
}
|
||||
if err := tx.Table("generation_outputs output").
|
||||
Select("sb.active_output_id=output.id AS active,sb.active_output_id=output.id OR coalesce(output.metadata->>'candidate','false')='true' AS candidate").
|
||||
Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.storyboard_id=? AND gt.task_type='video_generation'", storyboardID).
|
||||
Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id AND sb.deleted_at IS NULL").
|
||||
Joins("JOIN project_episodes e ON e.id=sb.episode_id AND e.deleted_at IS NULL").
|
||||
Joins("JOIN creative_projects p ON p.id=e.project_id AND p.id=? AND p.user_id=? AND p.project_type='premium_drama' AND p.deleted_at IS NULL", projectID, userID).
|
||||
Where("output.id=?", outputID).Take(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !row.Candidate {
|
||||
return errors.New("该视频不在备选列表中")
|
||||
}
|
||||
if row.Active {
|
||||
var replacement struct{ ID uuid.UUID }
|
||||
if err := tx.Table("generation_outputs output").Select("output.id").
|
||||
Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.storyboard_id=? AND gt.task_type='video_generation'", storyboardID).
|
||||
Where("output.id<>? AND coalesce(output.metadata->>'candidate','false')='true'", outputID).
|
||||
Order("output.created_at DESC").Limit(1).Scan(&replacement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var replacementID *uuid.UUID
|
||||
if replacement.ID != uuid.Nil {
|
||||
replacementID = &replacement.ID
|
||||
}
|
||||
status := "idle"
|
||||
if replacementID != nil {
|
||||
status = "completed"
|
||||
}
|
||||
if err := tx.Model(&model.EpisodeStoryboard{}).Where("id=?", storyboardID).
|
||||
Updates(map[string]any{"active_output_id": replacementID, "status": status}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Model(&model.GenerationOutput{}).Where("id=?", outputID).
|
||||
Update("metadata", gorm.Expr("coalesce(metadata,'{}'::jsonb)-'candidate'")).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Creative) ActivateStoryboardOutput(userID, projectID, storyboardID, outputID uuid.UUID) error {
|
||||
var output struct {
|
||||
MediaAssetID uuid.UUID
|
||||
TaskType string
|
||||
}
|
||||
if err := s.DB.Table("generation_outputs output").Select("output.media_asset_id,gt.task_type").
|
||||
Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.storyboard_id=? AND (gt.task_type='video_generation' OR (gt.task_type='image_generation' AND gt.input_data->>'target_type'='storyboard'))", storyboardID).
|
||||
Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id AND sb.deleted_at IS NULL").
|
||||
Joins(`JOIN creative_projects p ON p.id=? AND p.user_id=? AND p.deleted_at IS NULL AND
|
||||
(sb.project_id=p.id OR EXISTS (SELECT 1 FROM project_episodes e WHERE e.id=sb.episode_id AND e.project_id=p.id AND e.deleted_at IS NULL))`, projectID, userID).
|
||||
Where("output.id=?", outputID).Take(&output).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
updates := map[string]any{"status": "completed", "updated_at": time.Now()}
|
||||
if output.TaskType == "video_generation" {
|
||||
updates["active_output_id"] = outputID
|
||||
} else {
|
||||
updates["thumbnail_asset_id"] = output.MediaAssetID
|
||||
}
|
||||
result := s.DB.Model(&model.EpisodeStoryboard{}).Where("id=? AND deleted_at IS NULL", storyboardID).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Creative) DeleteStoryboardOutput(userID, projectID, storyboardID, outputID uuid.UUID) (string, error) {
|
||||
var objectKey string
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var row struct {
|
||||
TaskID uuid.UUID
|
||||
MediaID uuid.UUID
|
||||
ObjectKey string
|
||||
Status string
|
||||
Candidate bool
|
||||
}
|
||||
if err := tx.Table("generation_outputs output").Select("output.task_id,output.media_asset_id AS media_id,media.object_key,gt.status,sb.active_output_id=output.id OR coalesce(output.metadata->>'candidate','false')='true' AS candidate").
|
||||
Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.storyboard_id=? AND (gt.task_type='video_generation' OR (gt.task_type='image_generation' AND gt.input_data->>'target_type'='storyboard'))", storyboardID).
|
||||
Joins("JOIN media_assets media ON media.id=output.media_asset_id").
|
||||
Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id").
|
||||
Joins(`JOIN creative_projects p ON p.id=? AND p.user_id=? AND p.deleted_at IS NULL AND
|
||||
(sb.project_id=p.id OR EXISTS (SELECT 1 FROM project_episodes e WHERE e.id=sb.episode_id AND e.project_id=p.id AND e.deleted_at IS NULL))`, projectID, userID).
|
||||
Where("output.id=?", outputID).Take(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if row.Status == "pending_submission" || row.Status == "submitting" || row.Status == "submitted" || row.Status == "processing" || row.Status == "cancel_requested" {
|
||||
return errors.New("视频仍在生成中,暂时不能删除历史记录")
|
||||
}
|
||||
if row.Candidate {
|
||||
return errors.New("备选视频不能直接删除,请先移入历史记录")
|
||||
}
|
||||
if err := tx.Exec("UPDATE episode_storyboards SET active_output_id=NULL,status='idle' WHERE id=? AND active_output_id=?", storyboardID, outputID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("UPDATE episode_storyboards SET thumbnail_asset_id=NULL,status='idle' WHERE id=? AND thumbnail_asset_id=?", storyboardID, row.MediaID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM generation_outputs WHERE id=?", outputID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM generation_tasks WHERE id=?", row.TaskID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM channel_asset_cache WHERE media_asset_id=?", row.MediaID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM media_assets WHERE id=?", row.MediaID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
objectKey = row.ObjectKey
|
||||
return nil
|
||||
})
|
||||
return objectKey, err
|
||||
}
|
||||
|
||||
func (s *Creative) ListAssetOutputs(userID, projectID, assetID uuid.UUID) ([]map[string]any, error) {
|
||||
items := make([]map[string]any, 0)
|
||||
err := s.DB.Table("generation_outputs output").
|
||||
Select(`output.id,output.media_asset_id,output.output_type,output.sequence_no,output.metadata,output.created_at,output.task_id,
|
||||
media.public_url,media.mime_type,media.size_bytes,gt.estimated_points::text AS estimated_points,
|
||||
gt.actual_points::text AS actual_points,(a.image_asset_id=output.media_asset_id) AS active`).
|
||||
Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.task_type='image_generation' AND gt.input_data->>'asset_id'=?", assetID.String()).
|
||||
Joins("JOIN media_assets media ON media.id=output.media_asset_id AND media.deleted_at IS NULL").
|
||||
Joins("JOIN project_assets a ON a.id=? AND a.project_id=? AND a.deleted_at IS NULL", assetID, projectID).
|
||||
Joins("JOIN creative_projects p ON p.id=a.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID).
|
||||
Order("output.created_at DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (s *Creative) ActivateAssetOutput(userID, projectID, assetID, outputID uuid.UUID) error {
|
||||
result := s.DB.Exec(`UPDATE project_assets a SET image_asset_id=(SELECT media_asset_id FROM generation_outputs WHERE id=?),updated_at=CURRENT_TIMESTAMP
|
||||
WHERE a.id=? AND a.project_id=? AND a.deleted_at IS NULL AND EXISTS (
|
||||
SELECT 1 FROM generation_outputs output JOIN generation_tasks gt ON gt.id=output.task_id
|
||||
JOIN creative_projects p ON p.id=a.project_id
|
||||
WHERE output.id=? AND gt.task_type='image_generation' AND gt.input_data->>'asset_id'=? AND p.user_id=? AND p.deleted_at IS NULL)`,
|
||||
outputID, assetID, projectID, outputID, assetID.String(), userID)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Creative) DeleteAssetOutput(userID, projectID, assetID, outputID uuid.UUID) (string, error) {
|
||||
var objectKey string
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var row struct {
|
||||
TaskID uuid.UUID
|
||||
MediaID uuid.UUID
|
||||
ObjectKey string
|
||||
Status string
|
||||
}
|
||||
if err := tx.Table("generation_outputs output").Select("output.task_id,output.media_asset_id AS media_id,media.object_key,gt.status").
|
||||
Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.task_type='image_generation' AND gt.input_data->>'asset_id'=?", assetID.String()).
|
||||
Joins("JOIN media_assets media ON media.id=output.media_asset_id").
|
||||
Joins("JOIN project_assets a ON a.id=? AND a.project_id=? AND a.deleted_at IS NULL", assetID, projectID).
|
||||
Joins("JOIN creative_projects p ON p.id=a.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID).
|
||||
Where("output.id=?", outputID).Take(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, status := range activeTaskStatuses {
|
||||
if status == row.Status {
|
||||
return errors.New("鍥剧墖浠嶅湪鐢熸垚涓紝鏆傛椂涓嶈兘鍒犻櫎鍘嗗彶璁板綍")
|
||||
}
|
||||
}
|
||||
if err := tx.Exec("UPDATE project_assets SET image_asset_id=NULL WHERE id=? AND image_asset_id=?", assetID, row.MediaID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM generation_outputs WHERE id=?", outputID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM generation_tasks WHERE id=?", row.TaskID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM channel_asset_cache WHERE media_asset_id=?", row.MediaID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM media_assets WHERE id=?", row.MediaID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
objectKey = row.ObjectKey
|
||||
return nil
|
||||
})
|
||||
return objectKey, err
|
||||
}
|
||||
|
||||
func (s *Creative) UpdateEpisodeAnalysisSettings(userID, projectID, episodeID uuid.UUID, audioSource, sourceLanguage string) error {
|
||||
if audioSource != "video_audio" && audioSource != "subtitle_file" {
|
||||
return errors.New("音频来源无效")
|
||||
}
|
||||
sourceLanguage = strings.TrimSpace(sourceLanguage)
|
||||
if len(sourceLanguage) > 16 {
|
||||
return errors.New("源语言设置无效")
|
||||
}
|
||||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var episode model.ProjectEpisode
|
||||
if err := tx.Table("project_episodes e").Select("e.*").
|
||||
Joins("JOIN creative_projects p ON p.id=e.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID).
|
||||
Where("e.id=? AND e.project_id=? AND e.deleted_at IS NULL", episodeID, projectID).Take(&episode).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Selecting subtitle mode is persisted even before the file is uploaded.
|
||||
// The workbench disables analysis until a subtitle asset is present.
|
||||
updates := map[string]any{"audio_source": audioSource, "source_language": nil}
|
||||
if sourceLanguage != "" {
|
||||
updates["source_language"] = sourceLanguage
|
||||
}
|
||||
return tx.Model(&episode).Updates(updates).Error
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user