1894 lines
80 KiB
Go
1894 lines
80 KiB
Go
package service
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
|
||
"juhe-factory/api/internal/billing"
|
||
"juhe-factory/api/internal/model"
|
||
"juhe-factory/api/internal/provider/apimart"
|
||
queuepkg "juhe-factory/api/internal/queue"
|
||
"juhe-factory/api/internal/security"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/hibiken/asynq"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
var (
|
||
validEra = map[string]bool{
|
||
"modern_city": true, "ancient_history": true, "ancient_fantasy": true,
|
||
"ancient_xianxia": true, "future_scifi": true, "other": true,
|
||
}
|
||
validLocalization = map[string]bool{
|
||
"china": true, "uk_us": true, "korea": true, "japan": true, "france": true,
|
||
"russia": true, "vietnam": true, "thailand": true, "india": true,
|
||
}
|
||
)
|
||
|
||
func decodeJSONObject(value any) (map[string]any, error) {
|
||
if value == nil {
|
||
return map[string]any{}, nil
|
||
}
|
||
if object, ok := value.(map[string]any); ok {
|
||
return object, nil
|
||
}
|
||
var encoded []byte
|
||
switch raw := value.(type) {
|
||
case []byte:
|
||
encoded = raw
|
||
case json.RawMessage:
|
||
encoded = raw
|
||
case string:
|
||
encoded = []byte(raw)
|
||
default:
|
||
var err error
|
||
encoded, err = json.Marshal(raw)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
object := map[string]any{}
|
||
if len(encoded) == 0 {
|
||
return object, nil
|
||
}
|
||
if err := json.Unmarshal(encoded, &object); err != nil {
|
||
return nil, err
|
||
}
|
||
return object, nil
|
||
}
|
||
|
||
type Creative struct {
|
||
DB *gorm.DB
|
||
Queue *asynq.Client
|
||
Encryptor *security.Encryptor
|
||
HTTPClient *http.Client
|
||
}
|
||
|
||
type ProjectInput struct {
|
||
ProjectType string `json:"project_type"`
|
||
Name string `json:"name"`
|
||
StyleID string `json:"style_id"`
|
||
EraType string `json:"era_type"`
|
||
CustomEra *string `json:"custom_era"`
|
||
AspectRatio string `json:"aspect_ratio"`
|
||
Localization *string `json:"localization"`
|
||
ShortDramaType string `json:"short_drama_type"`
|
||
PlayCount string `json:"play_count"`
|
||
AudienceProfile string `json:"audience_profile"`
|
||
Producer string `json:"producer"`
|
||
CastMembers []ProjectCastMemberInput `json:"cast_members"`
|
||
}
|
||
|
||
type ProjectCastMemberInput struct {
|
||
Name string `json:"name"`
|
||
FollowerCount *string `json:"follower_count"`
|
||
}
|
||
|
||
type EpisodeInput struct {
|
||
EpisodeNo int `json:"episode_no"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
func (s *Creative) ListProjects(userID uuid.UUID, projectType, keyword string) ([]map[string]any, error) {
|
||
items := make([]map[string]any, 0)
|
||
query := s.DB.Table("creative_projects p").
|
||
Select(`p.id,p.project_type,p.name,p.era_type,p.custom_era,p.aspect_ratio,p.localization,
|
||
p.short_drama_type,p.play_count,p.audience_profile,p.producer,p.cast_members::text AS cast_members,p.updated_at,
|
||
ps.name AS style_name,
|
||
CASE WHEN p.project_type='video_redraw' THEN coalesce((
|
||
SELECT result.public_url
|
||
FROM episode_storyboards first_storyboard
|
||
JOIN generation_outputs active_output ON active_output.id=first_storyboard.active_output_id
|
||
JOIN media_assets result ON result.id=active_output.media_asset_id AND result.deleted_at IS NULL
|
||
WHERE first_storyboard.project_id=p.id AND first_storyboard.deleted_at IS NULL
|
||
ORDER BY first_storyboard.sequence_no LIMIT 1
|
||
),project_cover.public_url) ELSE project_cover.public_url END AS cover_url,
|
||
CASE WHEN p.project_type='video_redraw' THEN coalesce((
|
||
SELECT result.mime_type
|
||
FROM episode_storyboards first_storyboard
|
||
JOIN generation_outputs active_output ON active_output.id=first_storyboard.active_output_id
|
||
JOIN media_assets result ON result.id=active_output.media_asset_id AND result.deleted_at IS NULL
|
||
WHERE first_storyboard.project_id=p.id AND first_storyboard.deleted_at IS NULL
|
||
ORDER BY first_storyboard.sequence_no LIMIT 1
|
||
),project_cover.mime_type) ELSE project_cover.mime_type END AS cover_mime_type,
|
||
CASE WHEN p.project_type='premium_drama' THEN (
|
||
SELECT candidate_media.public_url
|
||
FROM project_episodes first_episode
|
||
JOIN episode_storyboards first_storyboard ON first_storyboard.id=(
|
||
SELECT storyboard.id
|
||
FROM episode_storyboards storyboard
|
||
WHERE storyboard.episode_id=first_episode.id AND storyboard.deleted_at IS NULL
|
||
ORDER BY storyboard.sequence_no LIMIT 1
|
||
)
|
||
JOIN generation_tasks candidate_task ON candidate_task.storyboard_id=first_storyboard.id AND candidate_task.task_type='video_generation'
|
||
JOIN generation_outputs candidate_output ON candidate_output.task_id=candidate_task.id
|
||
AND (first_storyboard.active_output_id=candidate_output.id OR coalesce(candidate_output.metadata->>'candidate','false')='true')
|
||
JOIN media_assets candidate_media ON candidate_media.id=candidate_output.media_asset_id AND candidate_media.deleted_at IS NULL
|
||
WHERE first_episode.id=(
|
||
SELECT episode.id FROM project_episodes episode
|
||
WHERE episode.project_id=p.id AND episode.deleted_at IS NULL
|
||
ORDER BY episode.episode_no LIMIT 1
|
||
)
|
||
ORDER BY candidate_output.created_at DESC
|
||
LIMIT 1
|
||
) END AS cover_video_url,
|
||
count(DISTINCT e.id) FILTER (WHERE e.deleted_at IS NULL) AS episode_count,
|
||
CASE WHEN p.project_type='video_redraw' THEN (
|
||
SELECT count(*) FROM generation_tasks task WHERE task.project_id=p.id AND task.status IN ('pending_submission','submitting','submitted','processing','result_ready','downloading','cancel_requested')
|
||
) ELSE count(DISTINCT e.id) FILTER (WHERE e.deleted_at IS NULL AND e.status IN ('analyzing','generating')) END AS processing_count`).
|
||
Joins("JOIN project_styles ps ON ps.id=p.style_id").
|
||
Joins("LEFT JOIN media_assets project_cover ON project_cover.id=p.cover_asset_id AND project_cover.deleted_at IS NULL").
|
||
Joins("LEFT JOIN project_episodes e ON e.project_id=p.id").
|
||
Where("p.user_id=? AND p.deleted_at IS NULL", userID)
|
||
if projectType != "" {
|
||
query = query.Where("p.project_type=?", projectType)
|
||
}
|
||
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||
query = query.Where("p.name::text ILIKE ?", "%"+keyword+"%")
|
||
}
|
||
if err := query.Group("p.id,ps.name,project_cover.public_url,project_cover.mime_type").Order("p.updated_at DESC").Find(&items).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if err := decodeProjectCastMembers(items); err != nil {
|
||
return nil, err
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *Creative) CreateProject(userID uuid.UUID, input ProjectInput) (*model.CreativeProject, error) {
|
||
input = normalizeCreateProjectInput(input)
|
||
if normalizedProjectType(input.ProjectType) == "video_redraw" && strings.TrimSpace(input.StyleID) == "" {
|
||
if err := s.DB.Table("project_styles").Select("id").Where("deleted_at IS NULL").Order("sort_order,name").Limit(1).Scan(&input.StyleID).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if strings.TrimSpace(input.StyleID) == "" {
|
||
return nil, errors.New("暂无可用视觉风格")
|
||
}
|
||
}
|
||
if err := validateProjectInput(input); err != nil {
|
||
return nil, err
|
||
}
|
||
styleID, err := uuid.Parse(input.StyleID)
|
||
if err != nil {
|
||
return nil, errors.New("项目风格无效")
|
||
}
|
||
var styleCount int64
|
||
if err := s.DB.Table("project_styles").Where("id=? AND deleted_at IS NULL", styleID).Count(&styleCount).Error; err != nil || styleCount == 0 {
|
||
return nil, errors.New("项目风格不可用")
|
||
}
|
||
project := &model.CreativeProject{
|
||
UserID: userID, ProjectType: normalizedProjectType(input.ProjectType), Name: strings.TrimSpace(input.Name),
|
||
StyleID: styleID, EraType: input.EraType, CustomEra: cleanOptional(input.CustomEra),
|
||
AspectRatio: input.AspectRatio, Localization: cleanOptional(input.Localization),
|
||
AudioSource: "video_audio", RedrawStatus: "draft",
|
||
}
|
||
if project.ProjectType == "video_redraw" {
|
||
project.ShortDramaType = cleanOptionalString(input.ShortDramaType)
|
||
project.PlayCount = cleanOptionalString(input.PlayCount)
|
||
project.AudienceProfile = cleanOptionalString(input.AudienceProfile)
|
||
project.Producer = cleanOptionalString(input.Producer)
|
||
project.CastMembers = marshalProjectCastMembers(input.CastMembers)
|
||
}
|
||
if err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||
if err := tx.Create(project).Error; err != nil {
|
||
return err
|
||
}
|
||
return tx.Exec(`INSERT INTO project_model_configs(id,project_id,purpose,model_type,model_id,prompt_id,settings)
|
||
SELECT gen_random_uuid(),?,purpose,model_type,model_id,prompt_id,settings
|
||
FROM user_model_configs WHERE user_id=? AND project_type=?`, project.ID, userID, project.ProjectType).Error
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
return project, nil
|
||
}
|
||
|
||
func normalizeCreateProjectInput(input ProjectInput) ProjectInput {
|
||
if strings.TrimSpace(input.ProjectType) == "" && input.Localization == nil {
|
||
input.ProjectType = "premium_drama"
|
||
}
|
||
if normalizedProjectType(input.ProjectType) == "video_redraw" {
|
||
if strings.TrimSpace(input.EraType) == "" {
|
||
input.EraType = "modern_city"
|
||
}
|
||
if strings.TrimSpace(input.AspectRatio) == "" {
|
||
input.AspectRatio = "9:16"
|
||
}
|
||
if input.Localization == nil || strings.TrimSpace(*input.Localization) == "" {
|
||
localization := "china"
|
||
input.Localization = &localization
|
||
}
|
||
}
|
||
return input
|
||
}
|
||
|
||
func (s *Creative) UpdateProject(userID, projectID uuid.UUID, input ProjectInput) error {
|
||
if err := s.DB.Table("creative_projects").Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID).Pluck("project_type", &input.ProjectType).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := validateProjectInput(input); err != nil {
|
||
return err
|
||
}
|
||
styleID, err := uuid.Parse(input.StyleID)
|
||
if err != nil {
|
||
return errors.New("项目风格无效")
|
||
}
|
||
updates := map[string]any{
|
||
"name": strings.TrimSpace(input.Name), "style_id": styleID, "era_type": input.EraType,
|
||
"custom_era": cleanOptional(input.CustomEra), "aspect_ratio": input.AspectRatio,
|
||
"localization": cleanOptional(input.Localization),
|
||
}
|
||
if normalizedProjectType(input.ProjectType) == "video_redraw" {
|
||
updates["short_drama_type"] = cleanOptionalString(input.ShortDramaType)
|
||
updates["play_count"] = cleanOptionalString(input.PlayCount)
|
||
updates["audience_profile"] = cleanOptionalString(input.AudienceProfile)
|
||
updates["producer"] = cleanOptionalString(input.Producer)
|
||
updates["cast_members"] = gorm.Expr("?::jsonb", string(marshalProjectCastMembers(input.CastMembers)))
|
||
}
|
||
result := s.DB.Table("creative_projects").Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID).Updates(updates)
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return gorm.ErrRecordNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateProjectInput(input ProjectInput) error {
|
||
if strings.TrimSpace(input.Name) == "" || len([]rune(strings.TrimSpace(input.Name))) > 100 {
|
||
return errors.New("项目名称必须为 1 至 100 个字符")
|
||
}
|
||
if !validEra[input.EraType] {
|
||
return errors.New("项目时代无效")
|
||
}
|
||
if input.EraType == "other" && (input.CustomEra == nil || strings.TrimSpace(*input.CustomEra) == "") {
|
||
return errors.New("请输入自定义时代")
|
||
}
|
||
if input.AspectRatio != "9:16" && input.AspectRatio != "16:9" {
|
||
return errors.New("项目尺寸无效")
|
||
}
|
||
projectType := normalizedProjectType(input.ProjectType)
|
||
if projectType != "video_redraw" && projectType != "premium_drama" {
|
||
return errors.New("项目类型无效")
|
||
}
|
||
if projectType == "video_redraw" && (input.Localization == nil || !validLocalization[strings.TrimSpace(*input.Localization)]) {
|
||
return errors.New("本土化地区无效")
|
||
}
|
||
if projectType == "video_redraw" {
|
||
if len([]rune(strings.TrimSpace(input.ShortDramaType))) > 100 {
|
||
return errors.New("短剧类型不能超过 100 个字符")
|
||
}
|
||
if len([]rune(strings.TrimSpace(input.PlayCount))) > 100 {
|
||
return errors.New("播放量不能超过 100 个字符")
|
||
}
|
||
if len([]rune(strings.TrimSpace(input.AudienceProfile))) > 2000 {
|
||
return errors.New("受众群体画像不能超过 2000 个字符")
|
||
}
|
||
if len([]rune(strings.TrimSpace(input.Producer))) > 200 {
|
||
return errors.New("出品方不能超过 200 个字符")
|
||
}
|
||
if len(input.CastMembers) > 50 {
|
||
return errors.New("主演不能超过 50 位")
|
||
}
|
||
for _, member := range input.CastMembers {
|
||
if strings.TrimSpace(member.Name) == "" || len([]rune(strings.TrimSpace(member.Name))) > 100 {
|
||
return errors.New("主演姓名必须为 1 至 100 个字符")
|
||
}
|
||
if member.FollowerCount != nil && len([]rune(strings.TrimSpace(*member.FollowerCount))) > 100 {
|
||
return errors.New("主演粉丝量不能超过 100 个字符")
|
||
}
|
||
}
|
||
}
|
||
if projectType == "premium_drama" && input.Localization != nil && strings.TrimSpace(*input.Localization) != "" {
|
||
return errors.New("短剧创作项目不需要本土化地区")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizedProjectType(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return "video_redraw"
|
||
}
|
||
return value
|
||
}
|
||
|
||
func cleanOptional(value *string) *string {
|
||
if value == nil || strings.TrimSpace(*value) == "" {
|
||
return nil
|
||
}
|
||
cleaned := strings.TrimSpace(*value)
|
||
return &cleaned
|
||
}
|
||
|
||
func cleanOptionalString(value string) *string {
|
||
return cleanOptional(&value)
|
||
}
|
||
|
||
func marshalProjectCastMembers(members []ProjectCastMemberInput) json.RawMessage {
|
||
cleaned := make([]ProjectCastMemberInput, 0, len(members))
|
||
for _, member := range members {
|
||
member.Name = strings.TrimSpace(member.Name)
|
||
member.FollowerCount = cleanOptional(member.FollowerCount)
|
||
cleaned = append(cleaned, member)
|
||
}
|
||
encoded, _ := json.Marshal(cleaned)
|
||
return encoded
|
||
}
|
||
|
||
func decodeProjectCastMembers(projects []map[string]any) error {
|
||
for _, project := range projects {
|
||
value := project["cast_members"]
|
||
if value == nil {
|
||
project["cast_members"] = []ProjectCastMemberInput{}
|
||
continue
|
||
}
|
||
var encoded []byte
|
||
switch raw := value.(type) {
|
||
case []byte:
|
||
encoded = raw
|
||
case json.RawMessage:
|
||
encoded = raw
|
||
case string:
|
||
encoded = []byte(raw)
|
||
default:
|
||
var err error
|
||
encoded, err = json.Marshal(raw)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
members := make([]ProjectCastMemberInput, 0)
|
||
if len(encoded) > 0 {
|
||
if err := json.Unmarshal(encoded, &members); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
project["cast_members"] = members
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Creative) GetProject(userID, projectID uuid.UUID) (map[string]any, error) {
|
||
project := map[string]any{}
|
||
err := s.DB.Table("creative_projects p").
|
||
Select("p.id,p.project_type,p.name,p.style_id,ps.name AS style_name,ps.image_url AS style_image_url,p.era_type,p.custom_era,p.aspect_ratio,p.localization,p.short_drama_type,p.play_count,p.audience_profile,p.producer,p.cast_members::text AS cast_members,p.cover_asset_id,cover.public_url AS cover_url,p.created_at,p.updated_at").
|
||
Joins("JOIN project_styles ps ON ps.id=p.style_id").
|
||
Joins("LEFT JOIN media_assets cover ON cover.id=p.cover_asset_id").
|
||
Where("p.id=? AND p.user_id=? AND p.deleted_at IS NULL", projectID, userID).Take(&project).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := decodeProjectCastMembers([]map[string]any{project}); err != nil {
|
||
return nil, err
|
||
}
|
||
configs := make([]map[string]any, 0)
|
||
if err := s.DB.Table("project_model_configs c").Select("c.id,c.purpose,c.model_type,c.model_id,m.name AS model_name,ch.name AS channel_name,c.prompt_id,c.settings").Joins("JOIN models m ON m.id=c.model_id").Joins("JOIN channels ch ON ch.id=m.channel_id").Where("c.project_id=?", projectID).Find(&configs).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
for _, config := range configs {
|
||
settings, err := decodeJSONObject(config["settings"])
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
config["settings"] = settings
|
||
if fmt.Sprint(config["model_type"]) == "video" {
|
||
if capabilities, ok := apimart.VideoCapabilities(fmt.Sprint(config["model_name"])); ok {
|
||
config["capabilities"] = capabilities
|
||
}
|
||
}
|
||
}
|
||
episodes, err := s.ListEpisodes(userID, projectID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
project["model_configs"] = configs
|
||
project["episodes"] = episodes
|
||
if fmt.Sprint(project["project_type"]) == "premium_drama" && len(episodes) > 0 {
|
||
project["cover_video_url"] = episodes[0]["cover_video_url"]
|
||
}
|
||
return project, nil
|
||
}
|
||
|
||
func (s *Creative) DeleteProject(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 deleted_at IS NULL", projectID, userID).Take(&project).Error; err != nil {
|
||
return err
|
||
}
|
||
var active int64
|
||
if err := tx.Table("generation_tasks").Where("project_id=? AND status IN ?", projectID, activeTaskStatuses).Count(&active).Error; err != nil {
|
||
return err
|
||
}
|
||
if active > 0 {
|
||
return errors.New("项目存在排队中或处理中的任务,暂时不能删除")
|
||
}
|
||
if err := tx.Table("drama_parse_tasks").Where("project_id=? AND status IN ?", projectID, []string{"queued", "running", "retry_wait", "cancel_requested"}).Count(&active).Error; err != nil {
|
||
return err
|
||
}
|
||
if active > 0 {
|
||
return errors.New("项目存在进行中的剧本解析任务,暂时不能删除")
|
||
}
|
||
projectPrefix := fmt.Sprintf("juyou_ran/video-redraw/projects/%s/%%", projectID)
|
||
if err := collectMediaByObjectKey(tx, media, projectPrefix); err != nil {
|
||
return err
|
||
}
|
||
queries := []*gorm.DB{
|
||
tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key").Joins("JOIN creative_projects project ON project.cover_asset_id=media.id").Where("project.id=?", projectID),
|
||
tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key").Joins("JOIN creative_projects project ON media.id=project.source_video_asset_id OR media.id=project.subtitle_asset_id").Where("project.id=?", projectID),
|
||
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.project_id=?", projectID),
|
||
tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key").Joins("JOIN project_assets asset ON media.id=asset.image_asset_id OR media.id=asset.audio_asset_id").Where("asset.project_id=?", projectID),
|
||
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=? OR storyboard.episode_id IN (SELECT id FROM project_episodes WHERE project_id=?)", projectID, projectID),
|
||
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("task.project_id=?", projectID),
|
||
}
|
||
for _, query := range queries {
|
||
if err := media.addQuery(query); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := tx.Exec(`UPDATE episode_storyboards SET active_output_id=NULL WHERE episode_id IN
|
||
(SELECT id FROM project_episodes WHERE project_id=?) OR project_id=?`, projectID, projectID).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("DELETE FROM generation_outputs WHERE task_id IN (SELECT id FROM generation_tasks WHERE project_id=?)", projectID).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("DELETE FROM generation_tasks WHERE project_id=?", projectID).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("DELETE FROM episode_continuity_contexts WHERE episode_id IN (SELECT id FROM project_episodes WHERE project_id=?)", projectID).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("DELETE FROM drama_parse_tasks WHERE project_id=?", projectID).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("DELETE FROM drama_parse_batches WHERE project_id=?", projectID).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("DELETE FROM drama_import_sessions WHERE project_id=?", projectID).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("DELETE FROM episode_storyboards WHERE project_id=? OR episode_id IN (SELECT id FROM project_episodes WHERE project_id=?)", projectID, projectID).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("DELETE FROM project_assets WHERE project_id=?", projectID).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("DELETE FROM project_episodes WHERE project_id=?", projectID).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("DELETE FROM project_model_configs WHERE project_id=?", projectID).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("DELETE FROM creative_projects WHERE id=?", projectID).Error; err != nil {
|
||
return err
|
||
}
|
||
return media.deleteRows(tx)
|
||
})
|
||
return media.objectKeys(), err
|
||
}
|
||
|
||
func (s *Creative) SaveModelConfigs(userID, projectID uuid.UUID, values map[string]any) error {
|
||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||
var projectType string
|
||
if err := tx.Table("creative_projects").Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID).Pluck("project_type", &projectType).Error; err != nil || projectType == "" {
|
||
return gorm.ErrRecordNotFound
|
||
}
|
||
configs := []struct{ ModelType, Purpose string }{
|
||
{ModelType: "text", Purpose: "prompt_reverse"},
|
||
{ModelType: "image", Purpose: "image_generation"},
|
||
{ModelType: "video", Purpose: "video_generation"},
|
||
}
|
||
for _, item := range configs {
|
||
raw, ok := values[item.ModelType]
|
||
if !ok {
|
||
raw, ok = values[item.Purpose]
|
||
}
|
||
if !ok || raw == nil {
|
||
continue
|
||
}
|
||
purpose := item.Purpose
|
||
settings := map[string]any{}
|
||
modelIDText := fmt.Sprint(raw)
|
||
if config, ok := raw.(map[string]any); ok {
|
||
modelIDText = fmt.Sprint(config["model_id"])
|
||
if supplied, ok := config["settings"].(map[string]any); ok {
|
||
for _, key := range []string{"prompt", "resolution"} {
|
||
if value, exists := supplied[key]; exists {
|
||
settings[key] = value
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Aspect ratio is not a model preference: images always use 16:9.
|
||
// Video quality belongs to this model configuration (not project settings)
|
||
// and defaults to 480p when omitted or invalid.
|
||
delete(settings, "aspect_ratio")
|
||
if purpose == "video_generation" {
|
||
resolution := strings.ToLower(strings.TrimSpace(fmt.Sprint(settings["resolution"])))
|
||
if resolution != "480p" && resolution != "720p" && resolution != "1080p" {
|
||
resolution = "480p"
|
||
}
|
||
settings["resolution"] = resolution
|
||
}
|
||
modelID, err := uuid.Parse(modelIDText)
|
||
if err != nil {
|
||
return errors.New("模型配置无效")
|
||
}
|
||
var available int64
|
||
query := tx.Table("models m").Joins("JOIN channels c ON c.id=m.channel_id").Where("m.id=? AND m.model_type=? AND m.enabled=true AND m.deleted_at IS NULL AND c.enabled=true AND c.deleted_at IS NULL", modelID, item.ModelType)
|
||
if purpose == "prompt_reverse" && projectType == "video_redraw" {
|
||
query = query.Where("m.multimodal=true")
|
||
}
|
||
if err := query.Count(&available).Error; err != nil || available == 0 {
|
||
return fmt.Errorf("%s 模型不可用", purpose)
|
||
}
|
||
encoded, err := json.Marshal(settings)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec(`INSERT INTO project_model_configs(id,project_id,purpose,model_type,model_id,settings) VALUES(?,?,?,?,?,?::jsonb)
|
||
ON CONFLICT(project_id,purpose) DO UPDATE SET model_type=excluded.model_type,model_id=excluded.model_id,settings=excluded.settings`, uuid.New(), projectID, purpose, item.ModelType, modelID, string(encoded)).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec(`INSERT INTO user_model_configs(id,user_id,project_type,purpose,model_type,model_id,settings) VALUES(?,?,?,?,?,?,?::jsonb)
|
||
ON CONFLICT(user_id,project_type,model_type) DO UPDATE SET purpose=excluded.purpose,model_id=excluded.model_id,settings=excluded.settings,updated_at=CURRENT_TIMESTAMP`, uuid.New(), userID, projectType, purpose, item.ModelType, modelID, string(encoded)).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func (s *Creative) ListUserModelPreferences(userID uuid.UUID, scope string) ([]map[string]any, error) {
|
||
if scope != "script_analysis" {
|
||
return nil, errors.New("模型偏好范围无效")
|
||
}
|
||
items := make([]map[string]any, 0)
|
||
err := s.DB.Table("user_model_configs").
|
||
Select("model_type,model_id,purpose,settings").
|
||
Where("user_id=? AND project_type=?", userID, scope).
|
||
Order("model_type").Find(&items).Error
|
||
return items, err
|
||
}
|
||
|
||
func (s *Creative) SaveUserModelPreference(userID uuid.UUID, scope, modelType, modelIDText string) error {
|
||
purposeByType := map[string]string{"text": "prompt_reverse"}
|
||
purpose, ok := purposeByType[modelType]
|
||
if scope != "script_analysis" || !ok {
|
||
return errors.New("模型偏好范围或类型无效")
|
||
}
|
||
modelID, err := uuid.Parse(strings.TrimSpace(modelIDText))
|
||
if err != nil {
|
||
return errors.New("模型偏好无效")
|
||
}
|
||
query := s.DB.Table("models m").Joins("JOIN channels c ON c.id=m.channel_id").
|
||
Where("m.id=? AND m.model_type=? AND m.enabled=true AND m.deleted_at IS NULL AND c.enabled=true AND c.deleted_at IS NULL", modelID, modelType)
|
||
var available int64
|
||
if err := query.Count(&available).Error; err != nil {
|
||
return err
|
||
}
|
||
if available == 0 {
|
||
return errors.New("模型不可用")
|
||
}
|
||
return s.DB.Exec(`INSERT INTO user_model_configs(id,user_id,project_type,purpose,model_type,model_id,settings)
|
||
VALUES(?,?,?,?,?,?,?::jsonb)
|
||
ON CONFLICT(user_id,project_type,model_type)
|
||
DO UPDATE SET purpose=excluded.purpose,model_id=excluded.model_id,settings=excluded.settings,updated_at=CURRENT_TIMESTAMP`,
|
||
uuid.New(), userID, scope, purpose, modelType, modelID, "{}").Error
|
||
}
|
||
|
||
func (s *Creative) ListEpisodes(userID, projectID uuid.UUID) ([]map[string]any, error) {
|
||
items := make([]map[string]any, 0)
|
||
err := s.DB.Table("project_episodes e").
|
||
Select(`e.id,e.episode_no,e.name,e.audio_source,e.source_language,e.status,e.analysis_message,e.source_video_asset_id,e.subtitle_asset_id,e.updated_at,
|
||
trim(coalesce(e.redraw_script,''))<>'' AS has_redraw_script,
|
||
exists(SELECT 1 FROM episode_sources text_source WHERE text_source.episode_id=e.id) AS has_text_source,
|
||
source.public_url AS source_video_url,source.original_name AS source_video_original_name,
|
||
sub.original_name AS subtitle_original_name,
|
||
(SELECT result.public_url
|
||
FROM episode_storyboards first_storyboard
|
||
JOIN generation_outputs active_output ON active_output.id=first_storyboard.active_output_id
|
||
JOIN media_assets result ON result.id=active_output.media_asset_id AND result.deleted_at IS NULL
|
||
WHERE first_storyboard.id=(
|
||
SELECT storyboard.id FROM episode_storyboards storyboard
|
||
WHERE storyboard.episode_id=e.id AND storyboard.deleted_at IS NULL
|
||
ORDER BY storyboard.sequence_no LIMIT 1
|
||
)) AS cover_url,
|
||
(SELECT candidate_media.public_url
|
||
FROM episode_storyboards first_storyboard
|
||
JOIN generation_tasks candidate_task ON candidate_task.storyboard_id=first_storyboard.id AND candidate_task.task_type='video_generation'
|
||
JOIN generation_outputs candidate_output ON candidate_output.task_id=candidate_task.id
|
||
AND (first_storyboard.active_output_id=candidate_output.id OR coalesce(candidate_output.metadata->>'candidate','false')='true')
|
||
JOIN media_assets candidate_media ON candidate_media.id=candidate_output.media_asset_id AND candidate_media.deleted_at IS NULL
|
||
WHERE first_storyboard.id=(
|
||
SELECT storyboard.id FROM episode_storyboards storyboard
|
||
WHERE storyboard.episode_id=e.id AND storyboard.deleted_at IS NULL
|
||
ORDER BY storyboard.sequence_no LIMIT 1
|
||
)
|
||
ORDER BY candidate_output.created_at DESC
|
||
LIMIT 1) AS cover_video_url,
|
||
count(DISTINCT sb.id) FILTER (WHERE sb.deleted_at IS NULL) AS storyboard_count,
|
||
count(DISTINCT sb.id) FILTER (WHERE sb.deleted_at IS NULL AND sb.status='completed') AS completed_storyboard_count`).
|
||
Joins("JOIN creative_projects p ON p.id=e.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID).
|
||
Joins("LEFT JOIN media_assets source ON source.id=e.source_video_asset_id").
|
||
Joins("LEFT JOIN media_assets sub ON sub.id=e.subtitle_asset_id").
|
||
Joins("LEFT JOIN episode_storyboards sb ON sb.episode_id=e.id").
|
||
Where("e.project_id=? AND e.deleted_at IS NULL", projectID).
|
||
Group("e.id,source.public_url,source.original_name,sub.original_name").Order("e.episode_no").Find(&items).Error
|
||
return items, err
|
||
}
|
||
|
||
func (s *Creative) CreateEpisode(userID, projectID uuid.UUID, input EpisodeInput) (*model.ProjectEpisode, error) {
|
||
var episode model.ProjectEpisode
|
||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||
var project model.CreativeProject
|
||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID).Take(&project).Error; err != nil {
|
||
return gorm.ErrRecordNotFound
|
||
}
|
||
episodeNo := input.EpisodeNo
|
||
if episodeNo < 1 {
|
||
if err := tx.Table("project_episodes").Select("coalesce(max(episode_no),0)+1").Where("project_id=? AND deleted_at IS NULL", projectID).Scan(&episodeNo).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
name := strings.TrimSpace(input.Name)
|
||
if name == "" {
|
||
name = fmt.Sprintf("第%d集", episodeNo)
|
||
}
|
||
exists, err := episodeNameExists(tx, projectID, nil, name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if exists {
|
||
return errors.New("同一项目下不可有同名剧集")
|
||
}
|
||
episode = model.ProjectEpisode{ProjectID: projectID, EpisodeNo: episodeNo, Name: name, AudioSource: "video_audio", Status: "draft"}
|
||
return tx.Create(&episode).Error
|
||
})
|
||
return &episode, err
|
||
}
|
||
|
||
func (s *Creative) UpdateEpisodeName(userID, projectID, episodeID uuid.UUID, name string) (*model.ProjectEpisode, error) {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return nil, errors.New("剧集名称不能为空")
|
||
}
|
||
if len([]rune(name)) > 160 {
|
||
return nil, errors.New("剧集名称不能超过160个字符")
|
||
}
|
||
var episode model.ProjectEpisode
|
||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||
if err := tx.Table("project_episodes episode").Select("episode.*").Joins("JOIN creative_projects project ON project.id=episode.project_id AND project.user_id=? 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 gorm.ErrRecordNotFound
|
||
}
|
||
exists, err := episodeNameExists(tx, projectID, &episodeID, name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if exists {
|
||
return errors.New("同一项目下不可有同名剧集")
|
||
}
|
||
if err := tx.Model(&episode).Update("name", name).Error; err != nil {
|
||
return err
|
||
}
|
||
episode.Name = name
|
||
return nil
|
||
})
|
||
return &episode, err
|
||
}
|
||
|
||
func episodeNameExists(tx *gorm.DB, projectID uuid.UUID, excludeEpisodeID *uuid.UUID, name string) (bool, error) {
|
||
var count int64
|
||
query := tx.Model(&model.ProjectEpisode{}).Where("project_id=? AND deleted_at IS NULL AND lower(name)=lower(?)", projectID, strings.TrimSpace(name))
|
||
if excludeEpisodeID != nil {
|
||
query = query.Where("id<>?", *excludeEpisodeID)
|
||
}
|
||
err := query.Count(&count).Error
|
||
return count > 0, err
|
||
}
|
||
|
||
func (s *Creative) GetWorkbench(userID, projectID, episodeID uuid.UUID) (map[string]any, error) {
|
||
project, err := s.GetProject(userID, projectID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
episode := map[string]any{}
|
||
err = s.DB.Table("project_episodes e").Select("e.*,source.public_url AS source_video_url,source.original_name AS source_video_original_name,sub.public_url AS subtitle_url,sub.original_name AS subtitle_original_name").Joins("LEFT JOIN media_assets source ON source.id=e.source_video_asset_id").Joins("LEFT JOIN media_assets sub ON sub.id=e.subtitle_asset_id").Where("e.id=? AND e.project_id=? AND e.deleted_at IS NULL", episodeID, projectID).Take(&episode).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
assets := make([]map[string]any, 0)
|
||
if err := s.DB.Table("project_assets a").Select("a.*,image.public_url AS image_url,image.object_key AS image_key,audio.public_url AS audio_url,audio.duration_ms AS audio_duration_ms,ref.id AS reference_image_asset_id,ref.public_url AS reference_image_url").Joins("LEFT JOIN media_assets image ON image.id=a.image_asset_id").Joins("LEFT JOIN media_assets audio ON audio.id=a.audio_asset_id").Joins("LEFT JOIN media_assets ref ON ref.id=(a.attributes->>'reference_image_asset_id')::uuid AND ref.deleted_at IS NULL").Where("a.project_id=? AND a.deleted_at IS NULL", projectID).Order("a.asset_type,a.created_at").Find(&assets).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
storyboards := make([]map[string]any, 0)
|
||
if err := s.DB.Table("episode_storyboards sb").Select(`sb.*,thumb.public_url AS thumbnail_url,
|
||
out.id AS output_id,result.id AS result_media_id,result.public_url AS result_url,result.mime_type AS result_mime,
|
||
coalesce((SELECT count(*) FROM generation_outputs history_output JOIN generation_tasks history_task ON history_task.id=history_output.task_id WHERE history_task.storyboard_id=sb.id AND history_task.task_type='video_generation'),0) AS history_count,
|
||
(SELECT gt.status FROM generation_tasks gt WHERE gt.storyboard_id=sb.id AND gt.task_type='video_generation' ORDER BY gt.created_at DESC LIMIT 1) AS task_status`).Joins("LEFT JOIN media_assets thumb ON thumb.id=sb.thumbnail_asset_id").Joins("LEFT JOIN generation_outputs out ON out.id=sb.active_output_id").Joins("LEFT JOIN media_assets result ON result.id=out.media_asset_id").Where("sb.episode_id=? AND sb.deleted_at IS NULL", episodeID).Order("sb.sequence_no").Find(&storyboards).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]any{"project": project, "episode": episode, "assets": assets, "storyboards": storyboards}, nil
|
||
}
|
||
|
||
func (s *Creative) CreateAsset(userID, projectID uuid.UUID, assetType, name string) (*model.ProjectAsset, error) {
|
||
if !map[string]bool{"character": true, "scene": true, "prop": true, "custom": true}[assetType] || strings.TrimSpace(name) == "" {
|
||
return nil, errors.New("资产类型或名称无效")
|
||
}
|
||
var owned int64
|
||
if err := s.DB.Table("creative_projects").Where("id=? AND user_id=? AND project_type<>'video_redraw' AND deleted_at IS NULL", projectID, userID).Count(&owned).Error; err != nil || owned == 0 {
|
||
return nil, gorm.ErrRecordNotFound
|
||
}
|
||
asset := &model.ProjectAsset{ProjectID: projectID, AssetType: assetType, Name: strings.TrimSpace(name), Appearances: json.RawMessage("[]")}
|
||
result := s.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(asset)
|
||
if result.Error != nil {
|
||
return nil, result.Error
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return nil, errors.New("同类型资产名称已存在")
|
||
}
|
||
return asset, nil
|
||
}
|
||
|
||
func (s *Creative) UpdateAsset(userID, projectID, assetID uuid.UUID, values map[string]any) error {
|
||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||
var current struct {
|
||
Name string
|
||
ProjectType string
|
||
}
|
||
if err := tx.Table("project_assets asset").Select("asset.name,project.project_type").
|
||
Joins("JOIN creative_projects project ON project.id=asset.project_id AND project.user_id=? AND project.deleted_at IS NULL", userID).
|
||
Where("asset.id=? AND asset.project_id=? AND asset.deleted_at IS NULL", assetID, projectID).Take(¤t).Error; err != nil {
|
||
return err
|
||
}
|
||
if current.ProjectType == "video_redraw" {
|
||
for key := range values {
|
||
if key != "name" {
|
||
return errors.New("剧本反推资产仅可修改名称")
|
||
}
|
||
}
|
||
}
|
||
allowed := map[string]any{}
|
||
for _, key := range []string{"name", "description", "image_prompt", "appearances"} {
|
||
if value, ok := values[key]; ok {
|
||
allowed[key] = value
|
||
}
|
||
}
|
||
newName := current.Name
|
||
if value, ok := allowed["name"]; ok {
|
||
newName = strings.TrimSpace(fmt.Sprint(value))
|
||
if newName == "" {
|
||
return errors.New("资产名称不能为空")
|
||
}
|
||
var duplicates int64
|
||
if err := tx.Table("project_assets candidate").
|
||
Where("candidate.project_id=? AND candidate.id<>? AND candidate.deleted_at IS NULL AND lower(candidate.name)=lower(?)", projectID, assetID, newName).
|
||
Where("candidate.asset_type=(SELECT asset_type FROM project_assets WHERE id=? AND project_id=? AND deleted_at IS NULL)", assetID, projectID).
|
||
Count(&duplicates).Error; err != nil {
|
||
return err
|
||
}
|
||
if duplicates > 0 {
|
||
return errors.New("同类型资产名称已存在")
|
||
}
|
||
allowed["name"] = newName
|
||
}
|
||
allowed["user_edited"] = true
|
||
result := tx.Table("project_assets").Where("id=? AND project_id=? AND deleted_at IS NULL", assetID, projectID).Updates(allowed)
|
||
if result.Error != nil {
|
||
if strings.Contains(result.Error.Error(), "uq_project_assets_name_type") {
|
||
return errors.New("同类型资产名称已存在")
|
||
}
|
||
return result.Error
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return gorm.ErrRecordNotFound
|
||
}
|
||
if newName != current.Name {
|
||
return syncAssetRename(tx, projectID, assetID, current.Name, newName)
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func syncAssetRename(tx *gorm.DB, projectID, assetID uuid.UUID, oldName, newName string) error {
|
||
protectedNames := make([]string, 0)
|
||
if err := tx.Table("project_assets").Where("project_id=? AND id<>? AND deleted_at IS NULL", projectID, assetID).Pluck("name", &protectedNames).Error; err != nil {
|
||
return err
|
||
}
|
||
var storyboards []model.EpisodeStoryboard
|
||
if err := tx.Select("id", "script_content", "prompt_content", "asset_refs").
|
||
Where(`deleted_at IS NULL AND (project_id=? OR episode_id IN (
|
||
SELECT id FROM project_episodes WHERE project_id=? AND deleted_at IS NULL
|
||
))`, projectID, projectID).Find(&storyboards).Error; err != nil {
|
||
return err
|
||
}
|
||
for _, storyboard := range storyboards {
|
||
updates := map[string]any{}
|
||
mentionNames, err := assetRefNames(storyboard.AssetRefs, assetID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
storyboardProtectedNames, err := otherAssetRefNames(storyboard.AssetRefs, assetID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
storyboardProtectedNames = append(storyboardProtectedNames, protectedNames...)
|
||
mentionNames = append(mentionNames, oldName)
|
||
scriptContent := storyboard.ScriptContent
|
||
promptContent := storyboard.PromptContent
|
||
seenNames := map[string]bool{}
|
||
for _, mentionName := range mentionNames {
|
||
mentionName = strings.TrimSpace(mentionName)
|
||
if mentionName == "" || seenNames[mentionName] {
|
||
continue
|
||
}
|
||
seenNames[mentionName] = true
|
||
scriptContent = renameAssetMentions(scriptContent, mentionName, newName, storyboardProtectedNames)
|
||
promptContent = renameAssetMentions(promptContent, mentionName, newName, storyboardProtectedNames)
|
||
}
|
||
if scriptContent != storyboard.ScriptContent {
|
||
value := scriptContent
|
||
updates["script_content"] = value
|
||
}
|
||
if promptContent != storyboard.PromptContent {
|
||
value := promptContent
|
||
updates["prompt_content"] = value
|
||
}
|
||
refs, changed, err := renameAssetRef(storyboard.AssetRefs, assetID, newName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if changed {
|
||
updates["asset_refs"] = gorm.Expr("?::jsonb", string(refs))
|
||
}
|
||
if len(updates) > 0 {
|
||
if err := tx.Model(&model.EpisodeStoryboard{}).Where("id=?", storyboard.ID).Updates(updates).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func renameAssetMentions(content, oldName, newName string, protectedNames []string) string {
|
||
oldName = strings.TrimSpace(oldName)
|
||
newName = strings.TrimSpace(newName)
|
||
if content == "" || oldName == "" || newName == "" || oldName == newName {
|
||
return content
|
||
}
|
||
needle := "@" + oldName
|
||
var result strings.Builder
|
||
searchFrom := 0
|
||
for searchFrom < len(content) {
|
||
relative := strings.Index(content[searchFrom:], needle)
|
||
if relative < 0 {
|
||
break
|
||
}
|
||
index := searchFrom + relative
|
||
result.WriteString(content[searchFrom:index])
|
||
if matchesProtectedMention(content[index+1:], oldName, protectedNames) {
|
||
result.WriteString(needle)
|
||
} else {
|
||
result.WriteString("@" + newName)
|
||
}
|
||
searchFrom = index + len(needle)
|
||
}
|
||
result.WriteString(content[searchFrom:])
|
||
return result.String()
|
||
}
|
||
|
||
func matchesProtectedMention(contentAfterAt, oldName string, protectedNames []string) bool {
|
||
for _, name := range protectedNames {
|
||
name = strings.TrimSpace(name)
|
||
if name != oldName && strings.HasPrefix(name, oldName) && strings.HasPrefix(contentAfterAt, name) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func assetRefNames(value json.RawMessage, assetID uuid.UUID) ([]string, error) {
|
||
if len(value) == 0 {
|
||
return nil, nil
|
||
}
|
||
var refs []map[string]any
|
||
if err := json.Unmarshal(value, &refs); err != nil {
|
||
return nil, err
|
||
}
|
||
names := make([]string, 0, 1)
|
||
for _, ref := range refs {
|
||
if fmt.Sprint(ref["id"]) == assetID.String() {
|
||
names = append(names, fmt.Sprint(ref["name"]))
|
||
}
|
||
}
|
||
return names, nil
|
||
}
|
||
|
||
func otherAssetRefNames(value json.RawMessage, assetID uuid.UUID) ([]string, error) {
|
||
if len(value) == 0 {
|
||
return nil, nil
|
||
}
|
||
var refs []map[string]any
|
||
if err := json.Unmarshal(value, &refs); err != nil {
|
||
return nil, err
|
||
}
|
||
names := make([]string, 0, len(refs))
|
||
for _, ref := range refs {
|
||
if fmt.Sprint(ref["id"]) != assetID.String() {
|
||
names = append(names, fmt.Sprint(ref["name"]))
|
||
}
|
||
}
|
||
return names, nil
|
||
}
|
||
|
||
func renameAssetRef(value json.RawMessage, assetID uuid.UUID, newName string) (json.RawMessage, bool, error) {
|
||
if len(value) == 0 {
|
||
return value, false, nil
|
||
}
|
||
var refs []map[string]any
|
||
if err := json.Unmarshal(value, &refs); err != nil {
|
||
return nil, false, err
|
||
}
|
||
changed := false
|
||
for _, ref := range refs {
|
||
if fmt.Sprint(ref["id"]) == assetID.String() && fmt.Sprint(ref["name"]) != newName {
|
||
ref["name"] = newName
|
||
changed = true
|
||
}
|
||
}
|
||
if !changed {
|
||
return value, false, nil
|
||
}
|
||
encoded, err := json.Marshal(refs)
|
||
return encoded, true, err
|
||
}
|
||
|
||
func (s *Creative) DeleteAsset(userID, projectID, assetID uuid.UUID) ([]string, error) {
|
||
media := newDeletionMediaSet()
|
||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||
var asset model.ProjectAsset
|
||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Table("project_assets asset").Select("asset.*").
|
||
Joins("JOIN creative_projects project ON project.id=asset.project_id AND project.user_id=? AND project.project_type<>'video_redraw' AND project.deleted_at IS NULL", userID).
|
||
Where("asset.id=? AND asset.project_id=? AND asset.deleted_at IS NULL", assetID, projectID).Take(&asset).Error; err != nil {
|
||
return err
|
||
}
|
||
assetTaskScope := "project_id=? AND task_type='image_generation' AND input_data->>'asset_id'=?"
|
||
var active int64
|
||
if err := tx.Model(&model.GenerationTask{}).Where(assetTaskScope+" AND status IN ?", projectID, assetID.String(), 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 task ON task.id=output.task_id").
|
||
Where("task."+assetTaskScope, projectID, assetID.String())); err != nil {
|
||
return err
|
||
}
|
||
if asset.ImageAssetID != nil {
|
||
if err := media.addQuery(tx.Table("media_assets").Select("id,object_key").Where("id=?", *asset.ImageAssetID)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if asset.AudioAssetID != nil {
|
||
if err := media.addQuery(tx.Table("media_assets").Select("id,object_key").Where("id=?", *asset.AudioAssetID)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
assetMediaPattern := fmt.Sprintf("juyou_ran/video-redraw/projects/%s/assets/%%/%s/%%", projectID, assetID)
|
||
if err := collectMediaByObjectKey(tx, media, assetMediaPattern); err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec(`UPDATE episode_storyboards storyboard SET
|
||
asset_refs=COALESCE((SELECT jsonb_agg(item) FROM jsonb_array_elements(COALESCE(storyboard.asset_refs,'[]'::jsonb)) item
|
||
WHERE item->>'id'<>?), '[]'::jsonb),
|
||
prompt_content=replace(COALESCE(storyboard.prompt_content,''), ?, ?)
|
||
WHERE storyboard.project_id=? OR storyboard.episode_id IN (SELECT id FROM project_episodes WHERE project_id=?)`, assetID.String(), "@"+asset.Name, asset.Name, projectID, projectID).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("UPDATE episode_storyboards SET active_output_id=NULL WHERE active_output_id IN (SELECT output.id FROM generation_outputs output JOIN generation_tasks task ON task.id=output.task_id WHERE task."+assetTaskScope+")", projectID, assetID.String()).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("DELETE FROM generation_outputs WHERE task_id IN (SELECT id FROM generation_tasks WHERE "+assetTaskScope+")", projectID, assetID.String()).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("DELETE FROM generation_tasks WHERE "+assetTaskScope, projectID, assetID.String()).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Exec("DELETE FROM project_assets WHERE id=?", assetID).Error; err != nil {
|
||
return err
|
||
}
|
||
return media.deleteRows(tx)
|
||
})
|
||
return media.objectKeys(), err
|
||
}
|
||
|
||
func ensureAssetImageHistory(tx *gorm.DB, userID, projectID, assetID, mediaID uuid.UUID) error {
|
||
var count int64
|
||
if err := tx.Table("generation_outputs output").
|
||
Joins("JOIN generation_tasks task ON task.id=output.task_id").
|
||
Where("output.media_asset_id=? AND task.project_id=? AND task.task_type='image_generation' AND task.input_data->>'asset_id'=?", mediaID, projectID, assetID.String()).
|
||
Count(&count).Error; err != nil {
|
||
return err
|
||
}
|
||
if count > 0 {
|
||
return nil
|
||
}
|
||
inputData, _ := json.Marshal(map[string]any{"asset_id": assetID.String(), "source": "manual_upload"})
|
||
taskID := uuid.New()
|
||
if err := tx.Exec(`INSERT INTO generation_tasks
|
||
(id,request_id,user_id,project_id,task_type,status,input_data,finished_at)
|
||
VALUES (?,?,?,?,?,'succeeded',?::jsonb,CURRENT_TIMESTAMP)`,
|
||
taskID, "asset_upload_"+uuid.NewString(), userID, projectID, "image_generation", string(inputData)).Error; err != nil {
|
||
return err
|
||
}
|
||
metadata, _ := json.Marshal(map[string]any{"source": "manual_upload"})
|
||
return tx.Create(&model.GenerationOutput{
|
||
ID: uuid.New(), TaskID: taskID, MediaAssetID: mediaID, OutputType: "image", SequenceNo: 1, Metadata: metadata,
|
||
}).Error
|
||
}
|
||
|
||
func (s *Creative) AttachUploadedAssetImage(userID, projectID, assetID uuid.UUID, media *model.MediaAsset) error {
|
||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||
if err := tx.Create(media).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := ensureAssetImageHistory(tx, userID, projectID, assetID, media.ID); err != nil {
|
||
return err
|
||
}
|
||
result := tx.Table("project_assets").Where(`id=? AND project_id=? AND deleted_at IS NULL AND EXISTS (
|
||
SELECT 1 FROM creative_projects WHERE id=project_assets.project_id AND user_id=? AND deleted_at IS NULL)`, assetID, projectID, userID).
|
||
Updates(map[string]any{"image_asset_id": media.ID, "user_edited": true})
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return gorm.ErrRecordNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func (s *Creative) RemoveAssetImage(userID, projectID, assetID uuid.UUID) error {
|
||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||
var row struct {
|
||
ImageAssetID *uuid.UUID
|
||
}
|
||
if err := tx.Table("project_assets a").Select("a.image_asset_id").
|
||
Joins("JOIN creative_projects p ON p.id=a.project_id AND p.user_id=? AND p.project_type<>'video_redraw' AND p.deleted_at IS NULL", userID).
|
||
Where("a.id=? AND a.project_id=? AND a.deleted_at IS NULL", assetID, projectID).Take(&row).Error; err != nil {
|
||
return err
|
||
}
|
||
if row.ImageAssetID == nil {
|
||
return nil
|
||
}
|
||
if err := ensureAssetImageHistory(tx, userID, projectID, assetID, *row.ImageAssetID); err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Table("project_assets").Where("id=?", assetID).Updates(map[string]any{"image_asset_id": nil, "user_edited": true}).Error; err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func (s *Creative) UpdateStoryboard(userID, projectID, storyboardID uuid.UUID, values map[string]any) error {
|
||
allowed := map[string]any{}
|
||
for _, key := range []string{"title", "script_content", "source_excerpt", "prompt_content", "image_prompt", "dialogue", "asset_refs", "locked", "active_output_id"} {
|
||
if value, ok := values[key]; ok {
|
||
if key == "asset_refs" || key == "dialogue" {
|
||
encoded, err := json.Marshal(value)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
value = gorm.Expr("?::jsonb", string(encoded))
|
||
}
|
||
allowed[key] = value
|
||
}
|
||
}
|
||
allowed["user_edited"] = true
|
||
if value, ok := values["duration_seconds"]; ok {
|
||
duration, err := strconv.Atoi(fmt.Sprint(value))
|
||
if err != nil {
|
||
return errors.New("分镜时长必须为整数")
|
||
}
|
||
min, max, err := s.videoDurationRange(userID, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if duration < min || duration > max {
|
||
return fmt.Errorf("分镜时长必须为 %d 到 %d 秒", min, max)
|
||
}
|
||
allowed["duration_seconds"] = duration
|
||
}
|
||
result := s.DB.Table("episode_storyboards sb").Where(`sb.id=? AND sb.deleted_at IS NULL AND EXISTS (
|
||
SELECT 1 FROM creative_projects p
|
||
WHERE 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)
|
||
))`, storyboardID, projectID, userID).Updates(allowed)
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return gorm.ErrRecordNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Creative) CreateStoryboard(userID, projectID, episodeID uuid.UUID) (*model.EpisodeStoryboard, error) {
|
||
if err := s.requirePremiumEpisode(userID, projectID, episodeID); err != nil {
|
||
return nil, err
|
||
}
|
||
var storyboard model.EpisodeStoryboard
|
||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||
var maxSequence int
|
||
if err := tx.Table("episode_storyboards").Where("episode_id=? AND deleted_at IS NULL", episodeID).Select("coalesce(max(sequence_no),0)").Scan(&maxSequence).Error; err != nil {
|
||
return err
|
||
}
|
||
start := int64(maxSequence * 5000)
|
||
storyboard = model.EpisodeStoryboard{ID: uuid.New(), EpisodeID: &episodeID, SequenceNo: maxSequence + 1, StableKey: uuid.NewString(), StartMS: start, EndMS: start + 5000, DurationSeconds: 5, Dialogue: json.RawMessage("[]"), AssetRefs: json.RawMessage("[]"), Status: "idle", UserEdited: true}
|
||
return tx.Create(&storyboard).Error
|
||
})
|
||
return &storyboard, err
|
||
}
|
||
|
||
func (s *Creative) QueueGeneration(userID, projectID uuid.UUID, episodeID *uuid.UUID, storyboardID uuid.UUID, taskType string, input map[string]any) (*model.GenerationTask, error) {
|
||
if taskType != "image_generation" && taskType != "video_generation" {
|
||
return nil, errors.New("生成任务类型无效")
|
||
}
|
||
if s.Queue == nil {
|
||
return nil, errors.New("生成任务队列不可用")
|
||
}
|
||
if input == nil {
|
||
input = map[string]any{}
|
||
}
|
||
var owned int64
|
||
ownedQuery := s.DB.Table("episode_storyboards sb").Joins("JOIN creative_projects p ON p.id=? AND p.user_id=? AND p.deleted_at IS NULL", projectID, userID).
|
||
Where("sb.id=? AND sb.deleted_at IS NULL", storyboardID)
|
||
if episodeID == nil {
|
||
ownedQuery = ownedQuery.Where("sb.project_id=p.id")
|
||
} else {
|
||
ownedQuery = ownedQuery.Where("sb.episode_id=? AND 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)", *episodeID)
|
||
}
|
||
if err := ownedQuery.Count(&owned).Error; err != nil || owned == 0 {
|
||
return nil, gorm.ErrRecordNotFound
|
||
}
|
||
if taskType == "video_generation" {
|
||
var projectType string
|
||
if err := s.DB.Table("creative_projects").Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID).Pluck("project_type", &projectType).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if projectType == "premium_drama" {
|
||
var candidateCount int64
|
||
if err := s.DB.Table("generation_outputs output").Joins("JOIN generation_tasks gt ON gt.id=output.task_id").
|
||
Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id").
|
||
Where("gt.storyboard_id=? AND gt.task_type='video_generation' AND (sb.active_output_id=output.id OR coalesce(output.metadata->>'candidate','false')='true')", storyboardID).
|
||
Count(&candidateCount).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if candidateCount >= 3 {
|
||
return nil, errors.New("当前分镜最多保留 3 个备选视频,请先删除一个备选视频")
|
||
}
|
||
}
|
||
var historyCount int64
|
||
if err := s.DB.Table("generation_outputs output").Joins("JOIN generation_tasks gt ON gt.id=output.task_id").Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id AND sb.active_output_id IS DISTINCT FROM output.id").Where("gt.storyboard_id=? AND gt.task_type='video_generation' AND coalesce(output.metadata->>'candidate','false')<>'true'", storyboardID).Count(&historyCount).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if historyCount >= 5 {
|
||
return nil, errors.New("当前分镜最多保留 5 条历史视频,请先删除历史记录")
|
||
}
|
||
}
|
||
if taskType == "image_generation" {
|
||
assetID, _ := input["asset_id"].(string)
|
||
var historyCount int64
|
||
query := s.DB.Table("generation_outputs output").Joins("JOIN generation_tasks gt ON gt.id=output.task_id").Where("gt.task_type='image_generation'")
|
||
if assetID != "" {
|
||
query = query.Joins("JOIN project_assets a ON a.id=? AND a.image_asset_id IS DISTINCT FROM output.media_asset_id", assetID).Where("gt.input_data->>'asset_id'=?", assetID)
|
||
} else {
|
||
query = query.Joins("JOIN episode_storyboards sb ON sb.id=? AND sb.thumbnail_asset_id IS DISTINCT FROM output.media_asset_id", storyboardID).Where("gt.storyboard_id=? AND gt.input_data->>'target_type'='storyboard'", storyboardID)
|
||
}
|
||
if err := query.Count(&historyCount).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if historyCount >= 5 {
|
||
return nil, errors.New("历史记录已达 5 条,请先删除历史记录")
|
||
}
|
||
}
|
||
purpose := taskType
|
||
var modelRow struct {
|
||
ModelID uuid.UUID
|
||
ChannelID uuid.UUID
|
||
ModelName string
|
||
ProjectType string
|
||
AspectRatio string
|
||
Price string
|
||
PriceExists bool
|
||
SettingsText string
|
||
}
|
||
priceKey := strings.TrimSpace(fmt.Sprint(input["resolution"]))
|
||
var configuredRow struct{ SettingsText string }
|
||
err := s.DB.Table("project_model_configs").Select("settings::text AS settings_text").Where("project_id=? AND purpose=?", projectID, purpose).Take(&configuredRow).Error
|
||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, err
|
||
}
|
||
configured, err := decodeJSONObject(configuredRow.SettingsText)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if priceKey == "" || priceKey == "<nil>" {
|
||
priceKey = strings.TrimSpace(fmt.Sprint(configured["resolution"]))
|
||
}
|
||
if priceKey == "" || priceKey == "<nil>" {
|
||
priceKey = map[string]string{"image_generation": "1k", "video_generation": "480p"}[taskType]
|
||
}
|
||
if priceKey == "" {
|
||
priceKey = "default"
|
||
}
|
||
err = s.DB.Raw(`SELECT m.id AS model_id,m.channel_id,m.name AS model_name,p.project_type,p.aspect_ratio,coalesce(mp.price,0)::text AS price,
|
||
(mp.id IS NOT NULL) AS price_exists,pc.settings::text AS settings_text
|
||
FROM project_model_configs pc JOIN models m ON m.id=pc.model_id
|
||
JOIN channels c ON c.id=m.channel_id
|
||
JOIN creative_projects p ON p.id=pc.project_id
|
||
LEFT JOIN model_prices mp ON mp.model_id=m.id AND lower(mp.price_key)=lower(?)
|
||
WHERE pc.project_id=? AND pc.purpose=? AND m.enabled=true AND m.deleted_at IS NULL AND c.enabled=true AND c.deleted_at IS NULL`, priceKey, projectID, purpose).Scan(&modelRow).Error
|
||
if err != nil || modelRow.ModelID == uuid.Nil {
|
||
return nil, errors.New("请先完成项目模型配置")
|
||
}
|
||
if !modelRow.PriceExists {
|
||
return nil, fmt.Errorf("当前%s模型未配置 %s 价格", map[string]string{"image_generation": "图片", "video_generation": "视频"}[taskType], priceKey)
|
||
}
|
||
duration := 1
|
||
var generationAssetID *uuid.UUID
|
||
input["model"] = modelRow.ModelName
|
||
configSettings, err := decodeJSONObject(modelRow.SettingsText)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// APIMart's video API uses `size` for the project framing ratio. Remove
|
||
// any legacy/client-supplied aspect_ratio value before persisting the task
|
||
// input so it cannot leak into the upstream request path.
|
||
delete(input, "aspect_ratio")
|
||
if taskType == "video_generation" {
|
||
capabilities, ok := apimart.VideoCapabilities(modelRow.ModelName)
|
||
if !ok {
|
||
return nil, errors.New("视频模型能力未配置")
|
||
}
|
||
// 短剧创作和视频转绘不开放参考视频,即使上游模型支持也不接受客户端传入。
|
||
delete(input, "video_urls")
|
||
// Video framing is a project-level setting. Model configuration only
|
||
// stores the resolution, so ignore any client-supplied ratio here.
|
||
input["size"] = firstNonEmpty(modelRow.AspectRatio, "16:9")
|
||
input["resolution"] = firstNonEmpty(fmt.Sprint(input["resolution"]), fmt.Sprint(configSettings["resolution"]), "480p")
|
||
var storyboard model.EpisodeStoryboard
|
||
if err := storyboardParentQuery(s.DB, projectID, episodeID, storyboardID).First(&storyboard).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
duration = storyboard.DurationSeconds
|
||
if duration < capabilities.DurationMinSeconds || duration > capabilities.DurationMaxSeconds {
|
||
return nil, fmt.Errorf("视频时长必须为 %d 到 %d 秒", capabilities.DurationMinSeconds, capabilities.DurationMaxSeconds)
|
||
}
|
||
input["duration"] = duration
|
||
input["prompt"] = firstNonEmpty(storyboard.PromptContent, storyboard.ScriptContent)
|
||
var refs []map[string]any
|
||
_ = json.Unmarshal(storyboard.AssetRefs, &refs)
|
||
refIDs := make([]uuid.UUID, 0, len(refs))
|
||
seenRefIDs := make(map[uuid.UUID]struct{}, len(refs))
|
||
for _, ref := range refs {
|
||
if id, err := uuid.Parse(fmt.Sprint(ref["id"])); err == nil {
|
||
if _, exists := seenRefIDs[id]; exists {
|
||
continue
|
||
}
|
||
seenRefIDs[id] = struct{}{}
|
||
refIDs = append(refIDs, id)
|
||
}
|
||
}
|
||
type referenceAsset struct {
|
||
ID uuid.UUID
|
||
Name, ImageURL, AudioURL string
|
||
AssetType string
|
||
AudioDurationMS *int64
|
||
MentionIndex int
|
||
}
|
||
mentionMode := modelRow.ProjectType == "premium_drama" && strings.Contains(storyboard.ScriptContent, "@")
|
||
candidateRows := make([]referenceAsset, 0)
|
||
if mentionMode || len(refIDs) > 0 {
|
||
query := s.DB.Table("project_assets a").
|
||
Select("a.id,a.name,a.asset_type,image.public_url AS image_url,audio.public_url AS audio_url,audio.duration_ms AS audio_duration_ms").
|
||
Joins("LEFT JOIN media_assets image ON image.id=a.image_asset_id AND image.deleted_at IS NULL").
|
||
Joins("LEFT JOIN media_assets audio ON audio.id=a.audio_asset_id AND audio.deleted_at IS NULL").
|
||
Where("a.project_id=? AND a.deleted_at IS NULL", projectID)
|
||
if mentionMode {
|
||
query = query.Where("a.asset_type='custom' OR a.id IN ?", refIDs)
|
||
} else {
|
||
query = query.Where("a.id IN ?", refIDs)
|
||
}
|
||
if err := query.Find(&candidateRows).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
selectedRows := make([]referenceAsset, 0, len(candidateRows)+1)
|
||
if mentionMode {
|
||
for _, row := range candidateRows {
|
||
if index := assetMentionIndex(storyboard.ScriptContent, row.Name); index >= 0 {
|
||
row.MentionIndex = index
|
||
selectedRows = append(selectedRows, row)
|
||
}
|
||
}
|
||
storyboardImageName := fmt.Sprintf("分镜 %d 分镜图", storyboard.SequenceNo)
|
||
if index := assetMentionIndex(storyboard.ScriptContent, storyboardImageName); index >= 0 && storyboard.ThumbnailAssetID != nil {
|
||
var media struct{ PublicURL string }
|
||
if err := s.DB.Table("media_assets").Select("public_url").Where("id=? AND deleted_at IS NULL", *storyboard.ThumbnailAssetID).Take(&media).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
selectedRows = append(selectedRows, referenceAsset{Name: storyboardImageName, ImageURL: media.PublicURL, AssetType: "storyboard_image", MentionIndex: index})
|
||
}
|
||
sort.SliceStable(selectedRows, func(left, right int) bool {
|
||
return selectedRows[left].MentionIndex < selectedRows[right].MentionIndex
|
||
})
|
||
}
|
||
if !mentionMode || len(selectedRows) == 0 {
|
||
selectedRows = selectedRows[:0]
|
||
byID := make(map[uuid.UUID]referenceAsset, len(candidateRows))
|
||
for _, row := range candidateRows {
|
||
byID[row.ID] = row
|
||
}
|
||
for _, refID := range refIDs {
|
||
row, exists := byID[refID]
|
||
if !exists {
|
||
return nil, errors.New("分镜引用了已删除或无权访问的项目资产")
|
||
}
|
||
selectedRows = append(selectedRows, row)
|
||
}
|
||
}
|
||
images := make([]string, 0)
|
||
audios := make([]string, 0)
|
||
imageNames := make([]string, 0)
|
||
audioNames := make([]string, 0)
|
||
var audioDurationMS int64
|
||
// 当前分镜已有分镜图时,无条件作为首张参考图参与视频生成;
|
||
// 若已在提及模式下通过 @分镜 N 分镜图 加入,则跳过避免重复。
|
||
storyboardImageMentioned := false
|
||
if mentionMode {
|
||
for _, row := range selectedRows {
|
||
if row.AssetType == "storyboard_image" {
|
||
storyboardImageMentioned = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if !storyboardImageMentioned && storyboard.ThumbnailAssetID != nil {
|
||
var media struct{ PublicURL string }
|
||
if err := s.DB.Table("media_assets").Select("public_url").Where("id=? AND deleted_at IS NULL", *storyboard.ThumbnailAssetID).Take(&media).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if media.PublicURL != "" {
|
||
images = append(images, media.PublicURL)
|
||
imageNames = append(imageNames, fmt.Sprintf("分镜 %d 分镜图", storyboard.SequenceNo))
|
||
}
|
||
}
|
||
for _, row := range selectedRows {
|
||
if row.ImageURL != "" {
|
||
if len(images) >= capabilities.ReferenceImages.MaxCount {
|
||
return nil, errors.New("参考图数量超限")
|
||
}
|
||
images = append(images, row.ImageURL)
|
||
imageNames = append(imageNames, row.Name)
|
||
}
|
||
if row.AudioURL != "" {
|
||
if len(audios) >= capabilities.ReferenceAudios.MaxCount {
|
||
return nil, errors.New("音频数量超限")
|
||
}
|
||
audios = append(audios, row.AudioURL)
|
||
audioNames = append(audioNames, row.Name)
|
||
if row.AudioDurationMS != nil {
|
||
audioDurationMS += *row.AudioDurationMS
|
||
}
|
||
}
|
||
}
|
||
if len(audios) > 0 && len(images) == 0 {
|
||
return nil, errors.New("禁止单独参考音频")
|
||
}
|
||
if capabilities.ReferenceAudios.MaxTotalDurationMS > 0 && audioDurationMS > capabilities.ReferenceAudios.MaxTotalDurationMS {
|
||
return nil, errors.New("音频时长超限")
|
||
}
|
||
input["image_urls"] = images
|
||
input["audio_urls"] = audios
|
||
if len(imageNames) > 0 || len(audioNames) > 0 {
|
||
mapping := []string{"参考资产映射(数组顺序与 URL 一致):"}
|
||
for index, name := range imageNames {
|
||
mapping = append(mapping, fmt.Sprintf("参考图%d ↔ @%s ↔ image_urls[%d]", index+1, name, index))
|
||
}
|
||
for index, name := range audioNames {
|
||
mapping = append(mapping, fmt.Sprintf("参考音频%d ↔ @%s ↔ audio_urls[%d]", index+1, name, index))
|
||
}
|
||
input["prompt"] = strings.Join(mapping, "\n") + "\n" + strings.TrimSpace(fmt.Sprint(input["prompt"]))
|
||
}
|
||
} else {
|
||
// Image generation always uses the standard landscape canvas. The
|
||
// image model config deliberately does not persist an aspect-ratio field.
|
||
input["size"] = "16:9"
|
||
input["resolution"] = firstNonEmpty(fmt.Sprint(input["resolution"]), fmt.Sprint(configSettings["resolution"]), "1k")
|
||
if fmt.Sprint(input["target_type"]) == "storyboard" {
|
||
var storyboard model.EpisodeStoryboard
|
||
if err := storyboardParentQuery(s.DB, projectID, episodeID, storyboardID).Take(&storyboard).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
input["target_type"] = "storyboard"
|
||
input["storyboard_id"] = storyboardID.String()
|
||
// 分镜图提示词优先使用客户端提交的提示词,其次回退到分镜保存的 image_prompt,
|
||
// 再回退到分镜内容(prompt_content/script_content),最后拼接"分镜图生成"提示词预设。
|
||
imagePrompt := firstNonEmpty(fmt.Sprint(input["prompt"]), storyboard.ImagePrompt, storyboard.PromptContent, storyboard.ScriptContent)
|
||
// 自动携带当前分镜关联的角色/场景/道具参考图(排除音色和未关联资产),
|
||
// 与客户端上传的临时参考图合并后随任务提交。
|
||
assetImages, err := storyboardReferenceImageURLs(s.DB, projectID, storyboard)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(assetImages) > 0 {
|
||
input["image_urls"] = append(assetImages, existingImageURLs(input["image_urls"])...)
|
||
}
|
||
promptParts := make([]string, 0, 2)
|
||
var preset struct{ Content string }
|
||
queryErr := s.DB.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, "分镜图生成").
|
||
Where("p.type=? AND p.deleted_at IS NULL", "分镜图生成").Limit(1).Find(&preset).Error
|
||
if queryErr != nil && !errors.Is(queryErr, gorm.ErrRecordNotFound) {
|
||
return nil, queryErr
|
||
}
|
||
if strings.TrimSpace(preset.Content) != "" {
|
||
promptParts = append(promptParts, strings.TrimSpace(preset.Content))
|
||
}
|
||
if imagePrompt != "" {
|
||
promptParts = append(promptParts, imagePrompt)
|
||
}
|
||
input["prompt"] = strings.Join(promptParts, "\n")
|
||
if strings.TrimSpace(fmt.Sprint(input["prompt"])) == "" {
|
||
return nil, errors.New("分镜提示词不能为空")
|
||
}
|
||
} else {
|
||
assetID, err := uuid.Parse(fmt.Sprint(input["asset_id"]))
|
||
if err != nil {
|
||
return nil, errors.New("请选择需要生成图片的资产")
|
||
}
|
||
generationAssetID = &assetID
|
||
var assetRow struct {
|
||
ImagePrompt string
|
||
AssetType string
|
||
ImageURL string
|
||
}
|
||
if err := s.DB.Table("project_assets a").Select("a.image_prompt,a.asset_type,coalesce(media.public_url,'') AS image_url").Joins("LEFT JOIN media_assets media ON media.id=a.image_asset_id AND media.deleted_at IS NULL").Where("a.id=? AND a.project_id=? AND a.deleted_at IS NULL", assetID, projectID).Take(&assetRow).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
input["asset_id"] = assetID.String()
|
||
promptParts := make([]string, 0, 2)
|
||
if promptType := assetPromptType(assetRow.AssetType); promptType != "" {
|
||
var preset struct{ Content string }
|
||
queryErr := s.DB.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", promptType).Take(&preset).Error
|
||
if queryErr != nil && !errors.Is(queryErr, gorm.ErrRecordNotFound) {
|
||
return nil, queryErr
|
||
}
|
||
if strings.TrimSpace(preset.Content) != "" {
|
||
promptParts = append(promptParts, strings.TrimSpace(preset.Content))
|
||
}
|
||
}
|
||
if strings.TrimSpace(assetRow.ImagePrompt) != "" {
|
||
promptParts = append(promptParts, strings.TrimSpace(assetRow.ImagePrompt))
|
||
}
|
||
input["prompt"] = strings.Join(promptParts, "\n")
|
||
}
|
||
}
|
||
payload, err := json.Marshal(input)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
task := &model.GenerationTask{
|
||
RequestID: "gen_" + uuid.NewString(), UserID: userID, ChannelID: &modelRow.ChannelID,
|
||
ModelID: &modelRow.ModelID, ProjectID: &projectID, EpisodeID: episodeID, StoryboardID: &storyboardID,
|
||
TaskType: taskType, Status: "pending_submission", InputData: payload,
|
||
}
|
||
err = s.DB.Transaction(func(tx *gorm.DB) error {
|
||
var active int64
|
||
if episodeID != nil {
|
||
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 taskType == "video_generation" {
|
||
var locked model.EpisodeStoryboard
|
||
if err := storyboardParentQuery(tx.Clauses(clause.Locking{Strength: "UPDATE"}), projectID, episodeID, storyboardID).Select("id").Take(&locked).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Model(&model.GenerationTask{}).Where("storyboard_id=? AND task_type=? AND status IN ?", storyboardID, taskType, activeTaskStatuses).Count(&active).Error; err != nil {
|
||
return err
|
||
}
|
||
} else {
|
||
if generationAssetID == nil {
|
||
var locked model.EpisodeStoryboard
|
||
if err := storyboardParentQuery(tx.Clauses(clause.Locking{Strength: "UPDATE"}), projectID, episodeID, storyboardID).Select("id").Take(&locked).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Model(&model.GenerationTask{}).Where("storyboard_id=? AND task_type='image_generation' AND input_data->>'target_type'='storyboard' AND status IN ?", storyboardID, activeTaskStatuses).Count(&active).Error; err != nil {
|
||
return err
|
||
}
|
||
} else {
|
||
var locked model.ProjectAsset
|
||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").Where("id=? AND project_id=? AND deleted_at IS NULL", *generationAssetID, projectID).Take(&locked).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Model(&model.GenerationTask{}).Where("project_id=? AND task_type=? AND input_data->>'asset_id'=? AND status IN ?", projectID, taskType, generationAssetID.String(), activeTaskStatuses).Count(&active).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
if active > 0 {
|
||
return errors.New(map[string]string{"image_generation": "当前目标已有生图任务正在执行", "video_generation": "当前分镜已有视频任务正在执行"}[taskType])
|
||
}
|
||
remark := map[string]string{"image_generation": "资产生成", "video_generation": "视频生成"}[taskType]
|
||
if err := billing.PrechargeGenerationTask(tx, task, modelRow.Price, duration, remark); err != nil {
|
||
return err
|
||
}
|
||
if taskType == "video_generation" {
|
||
return tx.Table("episode_storyboards").Where("id=?", storyboardID).Update("status", "queued").Error
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := queuepkg.EnqueueID(s.Queue, queuepkg.TypeDispatchChannel, modelRow.ChannelID, 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 storyboardParentQuery(db *gorm.DB, projectID uuid.UUID, episodeID *uuid.UUID, storyboardID uuid.UUID) *gorm.DB {
|
||
query := db.Model(&model.EpisodeStoryboard{}).Where("id=? AND deleted_at IS NULL", storyboardID)
|
||
if episodeID != nil {
|
||
return query.Where("episode_id=?", *episodeID)
|
||
}
|
||
return query.Where("project_id=?", projectID)
|
||
}
|
||
|
||
// videoDurationRange 返回当前项目视频生成模型允许的生成时长范围(秒),
|
||
// 用于分镜时长校验与更新,保证时长始终落在模型能力区间内。
|
||
func (s *Creative) videoDurationRange(userID, projectID uuid.UUID) (int, int, error) {
|
||
var modelRow struct {
|
||
ModelName string
|
||
}
|
||
err := s.DB.Table("project_model_configs pc").
|
||
Select("m.name AS model_name").
|
||
Joins("JOIN models m ON m.id=pc.model_id AND m.deleted_at IS NULL").
|
||
Joins("JOIN creative_projects p ON p.id=pc.project_id AND p.deleted_at IS NULL").
|
||
Where("pc.project_id=? AND pc.purpose='video_generation' AND p.user_id=?", projectID, userID).
|
||
Take(&modelRow).Error
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
capabilities, ok := apimart.VideoCapabilities(modelRow.ModelName)
|
||
if !ok {
|
||
return 0, 0, errors.New("视频模型能力未配置")
|
||
}
|
||
return capabilities.DurationMinSeconds, capabilities.DurationMaxSeconds, nil
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
if strings.TrimSpace(value) != "" && value != "<nil>" {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// storyboardReferenceImageURLs 返回当前分镜关联的角色/场景/道具资产中已上传图片的 URL 列表,
|
||
// 用于分镜图生成时自动携带参考图;排除音色资产和未关联资产,并去重。
|
||
func storyboardReferenceImageURLs(db *gorm.DB, projectID uuid.UUID, storyboard model.EpisodeStoryboard) ([]string, error) {
|
||
var refs []map[string]any
|
||
if err := json.Unmarshal(storyboard.AssetRefs, &refs); err != nil || len(refs) == 0 {
|
||
return nil, nil
|
||
}
|
||
seen := make(map[uuid.UUID]struct{}, len(refs))
|
||
refIDs := make([]uuid.UUID, 0, len(refs))
|
||
for _, ref := range refs {
|
||
id, err := uuid.Parse(fmt.Sprint(ref["id"]))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if _, ok := seen[id]; ok {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
refIDs = append(refIDs, id)
|
||
}
|
||
if len(refIDs) == 0 {
|
||
return nil, nil
|
||
}
|
||
var rows []struct {
|
||
ImageURL string
|
||
}
|
||
if err := db.Table("project_assets a").
|
||
Select("coalesce(image.public_url,'') AS image_url").
|
||
Joins("LEFT JOIN media_assets image ON image.id=a.image_asset_id AND image.deleted_at IS NULL").
|
||
Where("a.project_id=? AND a.deleted_at IS NULL AND a.id IN ? AND a.asset_type IN ?", projectID, refIDs, []string{"character", "scene", "prop"}).
|
||
Find(&rows).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
urls := make([]string, 0, len(rows))
|
||
seenURLs := make(map[string]struct{}, len(rows))
|
||
for _, row := range rows {
|
||
url := strings.TrimSpace(row.ImageURL)
|
||
if url == "" {
|
||
continue
|
||
}
|
||
if _, ok := seenURLs[url]; ok {
|
||
continue
|
||
}
|
||
seenURLs[url] = struct{}{}
|
||
urls = append(urls, url)
|
||
}
|
||
return urls, nil
|
||
}
|
||
|
||
// existingImageURLs 将请求 input 中可能为数组或字符串的 image_urls 字段归一化为字符串切片。
|
||
func existingImageURLs(value any) []string {
|
||
urls := make([]string, 0, 2)
|
||
switch typed := value.(type) {
|
||
case []any:
|
||
for _, item := range typed {
|
||
if text := strings.TrimSpace(fmt.Sprint(item)); text != "" {
|
||
urls = append(urls, text)
|
||
}
|
||
}
|
||
case []string:
|
||
urls = append(urls, typed...)
|
||
case string:
|
||
if text := strings.TrimSpace(typed); text != "" {
|
||
urls = append(urls, text)
|
||
}
|
||
}
|
||
return urls
|
||
}
|
||
|
||
func assetMentionIndex(content, name string) int {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return -1
|
||
}
|
||
needle := "@" + name
|
||
searchFrom := 0
|
||
for searchFrom < len(content) {
|
||
relative := strings.Index(content[searchFrom:], needle)
|
||
if relative < 0 {
|
||
return -1
|
||
}
|
||
index := searchFrom + relative
|
||
end := index + len(needle)
|
||
if end == len(content) {
|
||
return index
|
||
}
|
||
next, _ := utf8.DecodeRuneInString(content[end:])
|
||
if unicode.IsSpace(next) || unicode.IsPunct(next) {
|
||
return index
|
||
}
|
||
searchFrom = index + len("@")
|
||
}
|
||
return -1
|
||
}
|
||
|
||
type analysisModelConfig struct {
|
||
ModelID, ChannelID uuid.UUID
|
||
Pricing billing.TextPricing `gorm:"-"`
|
||
}
|
||
|
||
func (s *Creative) analysisModel(projectID uuid.UUID) (analysisModelConfig, error) {
|
||
var config analysisModelConfig
|
||
var err error
|
||
if err := s.DB.Raw(`SELECT m.id AS model_id,m.channel_id
|
||
FROM project_model_configs pc JOIN models m ON m.id=pc.model_id JOIN channels c ON c.id=m.channel_id
|
||
WHERE pc.project_id=? AND pc.purpose='prompt_reverse' AND m.model_type='text' AND m.multimodal=true
|
||
AND m.enabled=true AND c.enabled=true AND m.deleted_at IS NULL AND c.deleted_at IS NULL`, projectID).Scan(&config).Error; err != nil || config.ModelID == uuid.Nil {
|
||
return config, errors.New("请先配置反推模型")
|
||
}
|
||
config.Pricing, err = billing.LoadTextPricing(s.DB, config.ModelID)
|
||
if err != nil {
|
||
return config, fmt.Errorf("当前反推模型计费配置无效: %w", err)
|
||
}
|
||
return config, nil
|
||
}
|
||
|
||
func (s *Creative) QueueAnalysis(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: "analysis_" + uuid.NewString(), UserID: userID, ChannelID: &config.ChannelID, ModelID: &config.ModelID, ProjectID: &projectID, EpisodeID: &episodeID, TaskType: "prompt_reverse", Status: "submitted", InputData: json.RawMessage(`{}`), EstimatedPoints: "0.00", PrepaidPoints: "0.00"}
|
||
if 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
|
||
}
|
||
if episode.SourceVideoAssetID == nil {
|
||
return errors.New("请先上传原视频")
|
||
}
|
||
if episode.AudioSource == "subtitle_file" && episode.SubtitleAssetID == nil {
|
||
return errors.New("当前选择字幕文件识别,请先上传字幕")
|
||
}
|
||
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.Table("project_episodes").Where("id=?", episodeID).Updates(map[string]any{"status": "analyzing", "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)
|
||
}
|
||
return nil, errors.New("反推任务队列暂时不可用,请稍后重试")
|
||
}
|
||
return task, nil
|
||
}
|
||
|
||
func (s *Creative) QueueStoryboardAnalysis(userID, projectID, episodeID, 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
|
||
}
|
||
task := &model.GenerationTask{RequestID: "storyboard_analysis_" + uuid.NewString(), UserID: userID, ChannelID: &config.ChannelID, ModelID: &config.ModelID, ProjectID: &projectID, EpisodeID: &episodeID, StoryboardID: &storyboardID, TaskType: "prompt_reverse", Status: "submitted", InputData: json.RawMessage(`{"mode":"storyboard"}`), EstimatedPoints: "0.00", PrepaidPoints: "0.00"}
|
||
if 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
|
||
}
|
||
if episode.SourceVideoAssetID == nil {
|
||
return errors.New("请先上传原视频")
|
||
}
|
||
if episode.AudioSource == "subtitle_file" && episode.SubtitleAssetID == nil {
|
||
return errors.New("当前选择字幕文件识别,请先上传字幕")
|
||
}
|
||
var storyboard model.EpisodeStoryboard
|
||
if err := tx.Where("id=? AND episode_id=? AND deleted_at IS NULL", storyboardID, episodeID).Take(&storyboard).Error; err != nil {
|
||
return err
|
||
}
|
||
if storyboard.Locked {
|
||
return errors.New("当前分镜已保护,无法重新反推")
|
||
}
|
||
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("当前剧集已有分析任务正在执行")
|
||
}
|
||
return billing.CreateTextGenerationTask(tx, task, config.Pricing, "视频反推")
|
||
}); 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) ListOptions() (map[string]any, error) {
|
||
styles := make([]map[string]any, 0)
|
||
models := make([]map[string]any, 0)
|
||
if err := s.DB.Table("project_styles").Select("id,name,image_url").Where("deleted_at IS NULL").Order("sort_order,name").Find(&styles).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.DB.Table("models m").Select("m.id,m.name,m.model_type,m.multimodal,m.text_billing_mode,c.name AS channel_name").Joins("JOIN channels c ON c.id=m.channel_id").Where("m.enabled=true AND m.deleted_at IS NULL AND c.enabled=true AND c.deleted_at IS NULL").Order("m.model_type,m.name").Find(&models).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
for _, item := range models {
|
||
if fmt.Sprint(item["model_type"]) != "video" {
|
||
continue
|
||
}
|
||
if capabilities, ok := apimart.VideoCapabilities(fmt.Sprint(item["name"])); ok {
|
||
item["capabilities"] = capabilities
|
||
}
|
||
}
|
||
prices := make([]map[string]any, 0)
|
||
if err := s.DB.Table("model_prices").Select("model_id,price_key,unit,price").Find(&prices).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
promptTypes := []string{"剧本解析", "角色、场景、道具解析", "角色生成", "场景生成", "道具生成", "分镜图生成", "首尾帧生成", "视频生成"}
|
||
return map[string]any{"styles": styles, "models": models, "prices": prices, "prompt_types": promptTypes}, nil
|
||
}
|
||
|
||
func ParseUUID(value, label string) (uuid.UUID, error) {
|
||
id, err := uuid.Parse(value)
|
||
if err != nil {
|
||
return uuid.Nil, fmt.Errorf("%s无效", label)
|
||
}
|
||
return id, nil
|
||
}
|
||
|
||
func assetPromptType(assetType string) string {
|
||
switch strings.TrimSpace(assetType) {
|
||
case "character":
|
||
return "角色生成"
|
||
case "scene":
|
||
return "场景生成"
|
||
case "prop":
|
||
return "道具生成"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func IntValue(value any, fallback int) int {
|
||
parsed, err := strconv.Atoi(fmt.Sprint(value))
|
||
if err != nil {
|
||
return fallback
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
func NowPlus(seconds int) *time.Time {
|
||
value := time.Now().Add(time.Duration(seconds) * time.Second)
|
||
return &value
|
||
}
|