Files
JuYou/API/internal/service/creative.go
T
2026-08-25 17:59:42 +08:00

1894 lines
80 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(&current).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
}