444 lines
19 KiB
Go
444 lines
19 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"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"
|
|
)
|
|
|
|
func (s *Creative) GetRedrawWorkbench(userID, projectID uuid.UUID) (map[string]any, error) {
|
|
project, err := s.GetProject(userID, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
source := map[string]any{}
|
|
if err := s.DB.Table("creative_projects project").
|
|
Select(`project.source_video_asset_id,source.public_url AS source_video_url,source.original_name AS source_video_original_name,
|
|
project.subtitle_asset_id,subtitle.public_url AS subtitle_url,subtitle.original_name AS subtitle_original_name,
|
|
project.audio_source,project.source_language,project.redraw_status AS status,project.analysis_message`).
|
|
Joins("LEFT JOIN media_assets source ON source.id=project.source_video_asset_id AND source.deleted_at IS NULL").
|
|
Joins("LEFT JOIN media_assets subtitle ON subtitle.id=project.subtitle_asset_id AND subtitle.deleted_at IS NULL").
|
|
Where("project.id=? AND project.user_id=? AND project.project_type='video_redraw' AND project.deleted_at IS NULL", projectID, userID).
|
|
Take(&source).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
assets := make([]map[string]any, 0)
|
|
if err := s.DB.Table("project_assets asset").
|
|
Select("asset.id,asset.project_id,asset.asset_type,asset.name,asset.user_edited").
|
|
Where("asset.project_id=? AND asset.deleted_at IS NULL", projectID).
|
|
Order("asset.asset_type,asset.created_at").Find(&assets).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
storyboards := make([]map[string]any, 0)
|
|
if err := s.DB.Table("episode_storyboards storyboard").
|
|
Select(`storyboard.*,thumbnail.public_url AS thumbnail_url,0 AS history_count`).
|
|
Joins("LEFT JOIN media_assets thumbnail ON thumbnail.id=storyboard.thumbnail_asset_id").
|
|
Where("storyboard.project_id=? AND storyboard.deleted_at IS NULL", projectID).
|
|
Order("storyboard.sequence_no").Find(&storyboards).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var scriptRow struct {
|
|
RedrawScript string
|
|
}
|
|
if err := s.DB.Table("creative_projects").Select("coalesce(redraw_script,'') AS redraw_script").
|
|
Where("id=? AND user_id=? AND project_type='video_redraw' AND deleted_at IS NULL", projectID, userID).
|
|
Take(&scriptRow).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"project": project, "source": source, "assets": assets, "storyboards": storyboards, "script_content": scriptRow.RedrawScript}, nil
|
|
}
|
|
|
|
func (s *Creative) GetRedrawEpisodeWorkbench(userID, projectID, episodeID uuid.UUID) (map[string]any, error) {
|
|
data, err := s.GetWorkbench(userID, projectID, episodeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
episode, ok := data["episode"].(map[string]any)
|
|
if !ok {
|
|
return nil, errors.New("剧集数据无效")
|
|
}
|
|
var script string
|
|
if err := s.DB.Table("project_episodes episode").
|
|
Select("coalesce(episode.redraw_script,'')").
|
|
Joins("JOIN creative_projects project ON project.id=episode.project_id AND project.user_id=? AND project.project_type='video_redraw' AND project.deleted_at IS NULL", userID).
|
|
Where("episode.id=? AND episode.project_id=? AND episode.deleted_at IS NULL", episodeID, projectID).
|
|
Scan(&script).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{
|
|
"project": data["project"], "episode": episode, "source": episode,
|
|
"assets": data["assets"], "storyboards": data["storyboards"], "script_content": script,
|
|
}, nil
|
|
}
|
|
|
|
// SaveRedrawEpisodeScript 校验项目归属和类型后保存用户编辑的剧集反推剧本。
|
|
func (s *Creative) SaveRedrawEpisodeScript(userID, projectID, episodeID uuid.UUID, content string) error {
|
|
return s.DB.Transaction(func(tx *gorm.DB) error {
|
|
var episode model.ProjectEpisode
|
|
if err := tx.Table("project_episodes episode").Select("episode.id").
|
|
Joins("JOIN creative_projects project ON project.id=episode.project_id AND project.user_id=? AND project.project_type='video_redraw' AND project.deleted_at IS NULL", userID).
|
|
Where("episode.id=? AND episode.project_id=? AND episode.deleted_at IS NULL", episodeID, projectID).
|
|
Take(&episode).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Model(&model.ProjectEpisode{}).Where("id=?", episode.ID).Update("redraw_script", content).Error
|
|
})
|
|
}
|
|
|
|
func (s *Creative) QueueRedrawEpisodeScript(userID, projectID, episodeID uuid.UUID) (*model.GenerationTask, error) {
|
|
if s.Queue == nil {
|
|
return nil, errors.New("反推任务队列不可用")
|
|
}
|
|
config, err := s.analysisModel(projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
task := &model.GenerationTask{
|
|
RequestID: "script_reverse_" + uuid.NewString(), UserID: userID, ChannelID: &config.ChannelID,
|
|
ModelID: &config.ModelID, ProjectID: &projectID, EpisodeID: &episodeID, TaskType: "prompt_reverse", Status: "submitted",
|
|
InputData: json.RawMessage(`{"mode":"script"}`), EstimatedPoints: "0.00", PrepaidPoints: "0.00",
|
|
}
|
|
if err := s.DB.Transaction(func(tx *gorm.DB) error {
|
|
scriptPrompt, err := selectedPromptContent(tx, userID, "剧本反推")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
task.InputData = mustJSON(map[string]string{"mode": "script", "prompt": scriptPrompt})
|
|
var episode model.ProjectEpisode
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Table("project_episodes episode").Select("episode.*").
|
|
Joins("JOIN creative_projects project ON project.id=episode.project_id AND project.user_id=? AND project.project_type='video_redraw' AND project.deleted_at IS NULL", userID).
|
|
Where("episode.id=? AND episode.project_id=? AND episode.deleted_at IS NULL", episodeID, projectID).Take(&episode).Error; err != nil {
|
|
return err
|
|
}
|
|
if episode.Status != "review" {
|
|
return errors.New("请等待全部分镜提示词反推完成")
|
|
}
|
|
var total, incomplete int64
|
|
if err := tx.Model(&model.EpisodeStoryboard{}).Where("episode_id=? AND deleted_at IS NULL", episodeID).Count(&total).Error; err != nil {
|
|
return err
|
|
}
|
|
if total == 0 {
|
|
return errors.New("暂无可用于反推剧本的分镜提示词")
|
|
}
|
|
if err := tx.Model(&model.EpisodeStoryboard{}).
|
|
Where("episode_id=? AND deleted_at IS NULL AND trim(coalesce(prompt_content,''))=''", episodeID).
|
|
Count(&incomplete).Error; err != nil {
|
|
return err
|
|
}
|
|
if incomplete > 0 {
|
|
return errors.New("请等待所有分镜提示词反推完成")
|
|
}
|
|
var prompts []struct {
|
|
SequenceNo int `json:"sequence_no"`
|
|
StartMS int64 `json:"start_ms"`
|
|
EndMS int64 `json:"end_ms"`
|
|
PromptContent string `json:"prompt_content"`
|
|
}
|
|
if err := tx.Model(&model.EpisodeStoryboard{}).Select("sequence_no,start_ms,end_ms,prompt_content").
|
|
Where("episode_id=? AND deleted_at IS NULL", episodeID).Order("sequence_no").Find(&prompts).Error; err != nil {
|
|
return err
|
|
}
|
|
task.PromptSnapshot = string(mustJSON(prompts))
|
|
var active int64
|
|
if err := tx.Model(&model.GenerationTask{}).Where("episode_id=? AND task_type='prompt_reverse' AND status IN ?", episodeID, activeTaskStatuses).Count(&active).Error; err != nil {
|
|
return err
|
|
}
|
|
if active > 0 {
|
|
return errors.New("当前剧集已有反推任务正在执行")
|
|
}
|
|
if err := billing.CreateTextGenerationTask(tx, task, config.Pricing, "剧本反推"); err != nil {
|
|
return err
|
|
}
|
|
return tx.Model(&episode).Updates(map[string]any{"status": "generating", "analysis_message": "正在反推完整剧本"}).Error
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := queuepkg.EnqueueID(s.Queue, queuepkg.TypeAnalyzeEpisode, task.ID, 0); err != nil {
|
|
if settleErr := s.failQueuedTask(task.ID, "queue_unavailable", "剧本反推任务入队失败"); settleErr != nil {
|
|
return nil, fmt.Errorf("剧本反推任务入队失败且预扣返还失败: %w", settleErr)
|
|
}
|
|
_ = s.DB.Model(&model.ProjectEpisode{}).Where("id=?", episodeID).Updates(map[string]any{"status": "review", "analysis_message": "剧本反推任务入队失败"}).Error
|
|
return nil, errors.New("反推任务队列暂时不可用,请稍后重试")
|
|
}
|
|
return task, nil
|
|
}
|
|
|
|
func mustJSON(value any) []byte {
|
|
result, _ := json.Marshal(value)
|
|
return result
|
|
}
|
|
|
|
// selectedPromptContent 按用户偏好选取指定类型的提示词内容,未命中用户偏好时兜底取最新系统提示词。
|
|
// 用户未设置偏好或系统未配置均属于正常业务情况,使用 Limit(1).Find 避免触发 GORM 的 record not found 日志。
|
|
func selectedPromptContent(tx *gorm.DB, userID uuid.UUID, promptType string) (string, error) {
|
|
var selected struct{ Content string }
|
|
if err := tx.Table("prompts p").Select("p.content").
|
|
Joins("JOIN user_prompt_preferences pref ON pref.prompt_id=p.id AND pref.user_id=? AND pref.prompt_type=?", userID, promptType).
|
|
Where("p.type=? AND p.deleted_at IS NULL AND (p.scope='system' OR (p.scope='user' AND p.owner_user_id=?))", promptType, userID).
|
|
Limit(1).Find(&selected).Error; err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(selected.Content) == "" {
|
|
if err := tx.Table("prompts").Select("content").
|
|
Where("type=? AND scope='system' AND deleted_at IS NULL", promptType).
|
|
Order("updated_at DESC").Limit(1).Find(&selected).Error; err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(selected.Content) == "" {
|
|
return "", fmt.Errorf("请先在提示词管理中配置%s提示词", promptType)
|
|
}
|
|
}
|
|
return strings.TrimSpace(selected.Content), nil
|
|
}
|
|
|
|
func (s *Creative) UpdateRedrawAnalysisSettings(userID, projectID uuid.UUID, audioSource, sourceLanguage string) error {
|
|
if audioSource != "video_audio" && audioSource != "subtitle_file" {
|
|
return errors.New("台词来源无效")
|
|
}
|
|
sourceLanguage = strings.TrimSpace(sourceLanguage)
|
|
var language any
|
|
if sourceLanguage != "" {
|
|
language = sourceLanguage
|
|
}
|
|
result := s.DB.Table("creative_projects").
|
|
Where("id=? AND user_id=? AND project_type='video_redraw' AND deleted_at IS NULL", projectID, userID).
|
|
Updates(map[string]any{"audio_source": audioSource, "source_language": language})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Creative) QueueRedrawAnalysis(userID, projectID uuid.UUID) (*model.GenerationTask, error) {
|
|
return s.queueRedrawAnalysis(userID, projectID, nil)
|
|
}
|
|
|
|
func (s *Creative) QueueRedrawStoryboardAnalysis(userID, projectID, storyboardID uuid.UUID) (*model.GenerationTask, error) {
|
|
return s.queueRedrawAnalysis(userID, projectID, &storyboardID)
|
|
}
|
|
|
|
func (s *Creative) QueueRedrawScript(userID, projectID uuid.UUID) (*model.GenerationTask, error) {
|
|
if s.Queue == nil {
|
|
return nil, errors.New("反推任务队列不可用")
|
|
}
|
|
config, err := s.analysisModel(projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
task := &model.GenerationTask{
|
|
RequestID: "script_reverse_" + uuid.NewString(), UserID: userID, ChannelID: &config.ChannelID,
|
|
ModelID: &config.ModelID, ProjectID: &projectID, TaskType: "prompt_reverse", Status: "submitted",
|
|
InputData: json.RawMessage(`{"mode":"script"}`), EstimatedPoints: "0.00", PrepaidPoints: "0.00",
|
|
}
|
|
if err := s.DB.Transaction(func(tx *gorm.DB) error {
|
|
scriptPrompt, err := selectedPromptContent(tx, userID, "剧本反推")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
task.InputData = mustJSON(map[string]string{"mode": "script", "prompt": scriptPrompt})
|
|
var project model.CreativeProject
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("id=? AND user_id=? AND project_type='video_redraw' AND deleted_at IS NULL", projectID, userID).
|
|
Take(&project).Error; err != nil {
|
|
return err
|
|
}
|
|
if project.RedrawStatus != "review" {
|
|
return errors.New("请等待全部分镜提示词反推完成")
|
|
}
|
|
var total, incomplete int64
|
|
if err := tx.Model(&model.EpisodeStoryboard{}).Where("project_id=? AND deleted_at IS NULL", projectID).Count(&total).Error; err != nil {
|
|
return err
|
|
}
|
|
if total == 0 {
|
|
return errors.New("暂无可用于反推剧本的分镜提示词")
|
|
}
|
|
if err := tx.Model(&model.EpisodeStoryboard{}).
|
|
Where("project_id=? AND deleted_at IS NULL AND trim(coalesce(prompt_content,''))=''", projectID).
|
|
Count(&incomplete).Error; err != nil {
|
|
return err
|
|
}
|
|
if incomplete > 0 {
|
|
return errors.New("请等待所有分镜提示词反推完成")
|
|
}
|
|
var storyboardPrompts []struct {
|
|
SequenceNo int `json:"sequence_no"`
|
|
StartMS int64 `json:"start_ms"`
|
|
EndMS int64 `json:"end_ms"`
|
|
PromptContent string `json:"prompt_content"`
|
|
}
|
|
if err := tx.Model(&model.EpisodeStoryboard{}).Select("sequence_no,start_ms,end_ms,prompt_content").
|
|
Where("project_id=? AND deleted_at IS NULL", projectID).Order("sequence_no").Find(&storyboardPrompts).Error; err != nil {
|
|
return err
|
|
}
|
|
promptSnapshot, err := json.Marshal(storyboardPrompts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
task.PromptSnapshot = string(promptSnapshot)
|
|
var active int64
|
|
if err := tx.Model(&model.GenerationTask{}).Where("project_id=? AND task_type='prompt_reverse' AND status IN ?", projectID, activeTaskStatuses).Count(&active).Error; err != nil {
|
|
return err
|
|
}
|
|
if active > 0 {
|
|
return errors.New("当前剧本已有反推任务正在执行")
|
|
}
|
|
if err := billing.CreateTextGenerationTask(tx, task, config.Pricing, "剧本反推"); err != nil {
|
|
return err
|
|
}
|
|
return tx.Model(&project).Updates(map[string]any{"redraw_status": "generating", "analysis_message": "正在反推完整剧本"}).Error
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := queuepkg.EnqueueID(s.Queue, queuepkg.TypeAnalyzeEpisode, task.ID, 0); err != nil {
|
|
if settleErr := s.failQueuedTask(task.ID, "queue_unavailable", "剧本反推任务入队失败"); settleErr != nil {
|
|
return nil, fmt.Errorf("剧本反推任务入队失败且预扣返还失败: %w", settleErr)
|
|
}
|
|
_ = s.DB.Model(&model.CreativeProject{}).Where("id=?", projectID).Updates(map[string]any{"redraw_status": "review", "analysis_message": "剧本反推任务入队失败"}).Error
|
|
return nil, errors.New("反推任务队列暂时不可用,请稍后重试")
|
|
}
|
|
return task, nil
|
|
}
|
|
|
|
func (s *Creative) queueRedrawAnalysis(userID, projectID uuid.UUID, storyboardID *uuid.UUID) (*model.GenerationTask, error) {
|
|
if s.Queue == nil {
|
|
return nil, errors.New("反推任务队列不可用")
|
|
}
|
|
config, err := s.analysisModel(projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
requestPrefix := "analysis_"
|
|
input := json.RawMessage(`{}`)
|
|
if storyboardID != nil {
|
|
requestPrefix = "storyboard_analysis_"
|
|
input = json.RawMessage(`{"mode":"storyboard"}`)
|
|
}
|
|
task := &model.GenerationTask{
|
|
RequestID: requestPrefix + uuid.NewString(), UserID: userID, ChannelID: &config.ChannelID,
|
|
ModelID: &config.ModelID, ProjectID: &projectID, StoryboardID: storyboardID,
|
|
TaskType: "prompt_reverse", Status: "submitted", InputData: input, EstimatedPoints: "0.00", PrepaidPoints: "0.00",
|
|
}
|
|
if err := s.DB.Transaction(func(tx *gorm.DB) error {
|
|
var project model.CreativeProject
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("id=? AND user_id=? AND project_type='video_redraw' AND deleted_at IS NULL", projectID, userID).
|
|
Take(&project).Error; err != nil {
|
|
return err
|
|
}
|
|
if project.SourceVideoAssetID == nil {
|
|
return errors.New("请先上传原视频")
|
|
}
|
|
if project.AudioSource == "subtitle_file" && project.SubtitleAssetID == nil {
|
|
return errors.New("当前选择字幕文件识别,请先上传字幕")
|
|
}
|
|
if storyboardID != nil {
|
|
var storyboard model.EpisodeStoryboard
|
|
if err := tx.Where("id=? AND project_id=? AND deleted_at IS NULL", *storyboardID, projectID).Take(&storyboard).Error; err != nil {
|
|
return err
|
|
}
|
|
if storyboard.Locked {
|
|
return errors.New("当前分镜已保护,无法重新反推")
|
|
}
|
|
}
|
|
var active int64
|
|
if err := tx.Model(&model.GenerationTask{}).
|
|
Where("project_id=? AND task_type='prompt_reverse' AND status IN ?", projectID, activeTaskStatuses).
|
|
Count(&active).Error; err != nil {
|
|
return err
|
|
}
|
|
if active > 0 {
|
|
return errors.New("当前剧本已有反推任务正在执行")
|
|
}
|
|
if err := billing.CreateTextGenerationTask(tx, task, config.Pricing, "视频反推"); err != nil {
|
|
return err
|
|
}
|
|
if storyboardID == nil {
|
|
return tx.Model(&model.CreativeProject{}).Where("id=?", projectID).
|
|
Updates(map[string]any{"redraw_status": "analyzing", "analysis_message": "等待视频分析"}).Error
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := queuepkg.EnqueueID(s.Queue, queuepkg.TypeAnalyzeEpisode, task.ID, 0); err != nil {
|
|
if settleErr := s.failQueuedTask(task.ID, "queue_unavailable", "反推任务入队失败"); settleErr != nil {
|
|
return nil, fmt.Errorf("反推任务入队失败且预扣返还失败: %w", settleErr)
|
|
}
|
|
return nil, errors.New("反推任务队列暂时不可用,请稍后重试")
|
|
}
|
|
return task, nil
|
|
}
|
|
|
|
func (s *Creative) ResetRedrawAnalysis(userID, projectID uuid.UUID) ([]string, error) {
|
|
media := newDeletionMediaSet()
|
|
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
|
var project model.CreativeProject
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("id=? AND user_id=? AND project_type='video_redraw' AND deleted_at IS NULL", projectID, userID).
|
|
Take(&project).Error; err != nil {
|
|
return err
|
|
}
|
|
var active int64
|
|
if err := tx.Model(&model.GenerationTask{}).Where("project_id=? AND status IN ?", projectID, activeTaskStatuses).Count(&active).Error; err != nil {
|
|
return err
|
|
}
|
|
if active > 0 {
|
|
return errors.New("当前剧本存在进行中的任务,暂时不能重新反推")
|
|
}
|
|
if err := removeRedrawDerivedContent(tx, projectID, media); err != nil {
|
|
return err
|
|
}
|
|
if err := media.deleteRows(tx); err != nil {
|
|
return err
|
|
}
|
|
return tx.Model(&project).Updates(map[string]any{"redraw_status": "uploaded", "analysis_message": nil, "redraw_script": nil}).Error
|
|
})
|
|
return media.objectKeys(), err
|
|
}
|
|
|
|
func removeRedrawDerivedContent(tx *gorm.DB, projectID uuid.UUID, media *deletionMediaSet) error {
|
|
taskScope := `project_id=? AND (task_type IN ('prompt_reverse','video_generation')
|
|
OR (task_type='image_generation' AND input_data->>'target_type'='storyboard'))`
|
|
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 task ON task.id=output.task_id").Where(taskScope, projectID)); err != nil {
|
|
return err
|
|
}
|
|
if err := media.addQuery(tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key").
|
|
Joins("JOIN episode_storyboards storyboard ON storyboard.thumbnail_asset_id=media.id").
|
|
Where("storyboard.project_id=?", projectID)); err != nil {
|
|
return err
|
|
}
|
|
if err := collectMediaByObjectKey(tx, media, fmt.Sprintf("juyou_ran/video-redraw/projects/%s/storyboards/%%", projectID)); err != nil {
|
|
return err
|
|
}
|
|
if err := collectMediaByObjectKey(tx, media, fmt.Sprintf("juyou_ran/video-redraw/projects/%s/episodes/%%/storyboards/%%", projectID)); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&model.CreativeProject{}).Where("id=?", projectID).Update("cover_asset_id", nil).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Exec("UPDATE episode_storyboards SET active_output_id=NULL WHERE project_id=?", projectID).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Exec("DELETE FROM generation_outputs WHERE task_id IN (SELECT id FROM generation_tasks WHERE "+taskScope+")", projectID).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Exec("DELETE FROM generation_tasks WHERE "+taskScope, projectID).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Exec("DELETE FROM episode_storyboards WHERE project_id=?", projectID).Error
|
|
}
|