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 == "" { priceKey = strings.TrimSpace(fmt.Sprint(configured["resolution"])) } if priceKey == "" || priceKey == "" { 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 != "" { 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 }