Files
huobao-drama/application/services/storyboard_service.go
Connor 9600fc542c init
2026-01-12 13:17:11 +08:00

742 lines
27 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 services
import (
"strconv"
"fmt"
"strings"
models "github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/pkg/logger"
"github.com/drama-generator/backend/pkg/utils"
"gorm.io/gorm"
)
type StoryboardService struct {
db *gorm.DB
aiService *AIService
log *logger.Logger
}
func NewStoryboardService(db *gorm.DB, log *logger.Logger) *StoryboardService {
return &StoryboardService{
db: db,
aiService: NewAIService(db, log),
log: log,
}
}
type Storyboard struct {
ShotNumber int `json:"shot_number"`
Title string `json:"title"` // 镜头标题
ShotType string `json:"shot_type"` // 景别
Angle string `json:"angle"` // 镜头角度
Time string `json:"time"` // 时间
Location string `json:"location"` // 地点
SceneID *uint `json:"scene_id"` // 背景IDAI直接返回可为null
Movement string `json:"movement"` // 运镜
Action string `json:"action"` // 动作
Dialogue string `json:"dialogue"` // 对话/独白
Result string `json:"result"` // 画面结果
Atmosphere string `json:"atmosphere"` // 环境氛围
Emotion string `json:"emotion"` // 情绪
Duration int `json:"duration"` // 时长(秒)
BgmPrompt string `json:"bgm_prompt"` // 配乐提示词
SoundEffect string `json:"sound_effect"` // 音效描述
Characters []uint `json:"characters"` // 涉及的角色ID列表
IsPrimary bool `json:"is_primary"` // 是否主镜
}
type GenerateStoryboardResult struct {
Storyboards []Storyboard `json:"storyboards"`
Total int `json:"total"`
}
func (s *StoryboardService) GenerateStoryboard(episodeID string) (*GenerateStoryboardResult, error) {
// 从数据库获取剧集信息
var episode struct {
ID string
ScriptContent *string
Description *string
DramaID string
}
err := s.db.Table("episodes").
Select("episodes.id, episodes.script_content, episodes.description, episodes.drama_id").
Joins("INNER JOIN dramas ON dramas.id = episodes.drama_id").
Where("episodes.id = ?", episodeID).
First(&episode).Error
if err != nil {
return nil, fmt.Errorf("剧集不存在或无权限访问")
}
// 获取剧本内容
var scriptContent string
if episode.ScriptContent != nil && *episode.ScriptContent != "" {
scriptContent = *episode.ScriptContent
} else if episode.Description != nil && *episode.Description != "" {
scriptContent = *episode.Description
} else {
return nil, fmt.Errorf("剧本内容为空,请先生成剧集内容")
}
// 获取该剧本的所有角色
var characters []models.Character
if err := s.db.Where("drama_id = ?", episode.DramaID).Order("name ASC").Find(&characters).Error; err != nil {
return nil, fmt.Errorf("获取角色列表失败: %w", err)
}
// 构建角色列表字符串包含ID和名称
characterList := "无角色"
if len(characters) > 0 {
var charInfoList []string
for _, char := range characters {
charInfoList = append(charInfoList, fmt.Sprintf(`{"id": %d, "name": "%s"}`, char.ID, char.Name))
}
characterList = fmt.Sprintf("[%s]", strings.Join(charInfoList, ", "))
}
// 获取该项目已提取的场景列表(项目级)
var scenes []models.Scene
if err := s.db.Where("drama_id = ?", episode.DramaID).Order("location ASC, time ASC").Find(&scenes).Error; err != nil {
s.log.Warnw("Failed to get scenes", "error", err)
}
// 构建场景列表字符串包含ID、地点、时间
sceneList := "无场景"
if len(scenes) > 0 {
var sceneInfoList []string
for _, bg := range scenes {
sceneInfoList = append(sceneInfoList, fmt.Sprintf(`{"id": %d, "location": "%s", "time": "%s"}`, bg.ID, bg.Location, bg.Time))
}
sceneList = fmt.Sprintf("[%s]", strings.Join(sceneInfoList, ", "))
}
s.log.Infow("Generating storyboard",
"episode_id", episodeID,
"drama_id", episode.DramaID,
"script_length", len(scriptContent),
"character_count", len(characters),
"characters", characterList,
"scene_count", len(scenes),
"scenes", sceneList)
// 构建分镜头生成提示词
prompt := fmt.Sprintf(`【角色】你是一位资深影视分镜师,精通罗伯特·麦基的镜头拆解理论,擅长构建情绪节奏。
【任务】将小说剧本按**独立动作单元**拆解为分镜头方案。
【本剧可用角色列表】
%s
**重要**在characters字段中只能使用上述角色列表中的角色ID数字不得自创角色或使用其他ID。
【本剧已提取的场景背景列表】
%s
**重要**在scene_id字段中必须从上述背景列表中选择最匹配的背景ID数字。如果没有合适的背景则填null。
【剧本原文】
%s
【分镜要素】每个镜头聚焦单一动作,描述要详尽具体:
1. **镜头标题(title)**用3-5个字概括该镜头的核心内容或情绪
- 例如:"噩梦惊醒"、"对视沉思"、"逃离现场"、"意外发现"
2. **时间**[清晨/午后/深夜/具体时分+详细光线描述]
- 例如:"深夜22:30·月光从破窗斜射入室内形成明暗分界"
3. **地点**[场景完整描述+空间布局+环境细节]
- 例如:"废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱"
4. **镜头设计**
- **景别(shot_type)**[远景/全景/中景/近景/特写]
- **镜头角度(angle)**[平视/仰视/俯视/侧面/背面]
- **运镜方式(movement)**[固定镜头/推镜/拉镜/摇镜/跟镜/移镜]
5. **人物行为****详细动作描述**,包含[谁+具体怎么做+肢体细节+表情状态]
- 例如:"陈峥弯腰用撬棍撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水滑落脸颊"
6. **对话/独白**:提取该镜头中的完整对话或独白内容(如无对话则为空字符串)
7. **画面结果**:动作的即时后果+视觉细节+氛围变化
- 例如:"保险箱门弹开发出金属碰撞声,扬起灰尘在光束中飘散,箱内空无一物只有陈旧报纸,陈峥表情从期待转为失望"
8. **环境氛围**:光线质感+色调+声音环境+整体氛围
- 例如:"昏暗冷色调,只有手电筒光束晃动,远处传来海浪拍打声,压抑沉闷"
9. **配乐提示(bgm_prompt)**:描述该镜头配乐的氛围、节奏、情绪(如无特殊要求则为空字符串)
- 例如:"低沉紧张的弦乐,节奏缓慢,营造压抑氛围"
10. **音效描述(sound_effect)**:描述该镜头的关键音效(如无特殊音效则为空字符串)
- 例如:"金属碰撞声、脚步声、海浪拍打声"
11. **观众情绪**[情绪类型][强度:↑↑↑/↑↑/↑/→/↓] + [落点:悬置/释放/反转]
【输出格式】请以JSON格式输出每个镜头包含以下字段**所有描述性字段都要详细完整**
{
"storyboards": [
{
"shot_number": 1,
"title": "噩梦惊醒",
"shot_type": "全景",
"angle": "俯视45度角",
"time": "深夜22:30·月光从破窗斜射入仓库在地面积水中形成银白色反光墙角昏暗不清",
"location": "废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱和渔网,空气中弥漫潮湿霉味",
"scene_id": 1,
"movement": "固定镜头",
"action": "陈峥弯腰双手握住撬棍用力撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水从额头滑落脸颊,呼吸急促",
"dialogue": "(独白)这么多年了,里面到底藏着什么秘密?",
"result": "保险箱门突然弹开发出刺耳金属声,扬起灰尘在手电筒光束中飘散,箱内空无一物只有几张发黄的旧报纸,陈峥表情从期待转为震惊和失望,瞳孔放大",
"atmosphere": "昏暗冷色调·青灰色为主,只有手电筒光束在黑暗中晃动,远处传来海浪拍打码头的沉闷声,整体氛围压抑沉重",
"emotion": "好奇感↑↑转失望↓(情绪反转)",
"duration": 9,
"bgm_prompt": "低沉紧张的弦乐,节奏缓慢,营造压抑悬疑氛围",
"sound_effect": "金属碰撞声、灰尘飘散声、海浪拍打声",
"characters": [159],
"is_primary": true
},
{
"shot_number": 2,
"title": "对视沉思",
"shot_type": "近景",
"angle": "平视",
"time": "深夜22:31·仓库内光线昏暗只有手电筒光从侧面照亮两人脸部轮廓",
"location": "废弃码头仓库·保险箱旁,背景是模糊的货架剪影",
"scene_id": 1,
"movement": "推镜",
"action": "陈峥缓缓转身,目光与身后的李芳对视,李芳手握手电筒,光束在两人之间晃动,眼神中透露疑惑和警惕",
"dialogue": "陈峥:\"我们被耍了,这里根本没有我们要找的东西。\" 李芳:\"现在怎么办?我们的时间不多了。\"",
"result": "两人站在昏暗中陷入沉思,手电筒光束照在地面形成圆形光斑,背景传来微弱的金属摩擦声,气氛紧张凝重",
"atmosphere": "低调光线·暗部占画面70%,侧面硬光勾勒人物轮廓,冷暖光对比强烈,海风吹过产生呼啸声,营造紧迫感",
"emotion": "紧张感↑↑·警惕↑↑(悬置)",
"duration": 7,
"bgm_prompt": "紧张感逐渐升级的音效,低频持续音",
"sound_effect": "呼吸声、金属摩擦声、海风呼啸声",
"characters": [159, 160],
"is_primary": true
}
]
}
**dialogue字段说明**
- 如果有对话,格式为:角色名:\"台词内容\"
- 多人对话用空格分隔角色A\"...\" 角色B\"...\"
- 独白格式为:(独白)内容
- 旁白格式为:(旁白)内容
- 无对话时填写空字符串:""
- **对话内容必须从原剧本中提取,保持原汁原味**
**角色和背景要求**
- characters字段必须包含该镜头中出现的所有角色ID数字数组格式
- 只提取实际出现的角色ID不出现角色则为空数组[]
- **角色ID必须严格使用【本剧可用角色列表】中的id字段数字不得使用其他ID或自创角色**
- 例如:如果镜头中出现李明(id:159)和王芳(id:160)则characters字段应为[159, 160]
- scene_id字段必须从【本剧已提取的场景背景列表】中选择最匹配的背景ID数字
- 如果列表中没有合适的背景则scene_id填null
- 例如:如果镜头发生在"城市公寓卧室·凌晨"应选择id为1的场景背景
**duration时长估算规则**
- **所有镜头时长必须在4-12秒范围内**,确保节奏合理流畅
- **综合估算原则**:时长由对话内容、动作复杂度、情绪节奏三方面综合决定
**估算步骤**
1. **基础时长**(从场景内容判断):
- 纯对话场景无明显动作基础4秒
- 纯动作场景无对话基础5秒
- 对话+动作混合场景基础6秒
2. **对话调整**(根据台词字数增加时长):
- 无对话:+0秒
- 短对话1-20字+1-2秒
- 中等对话21-50字+2-4秒
- 长对话51字以上+4-6秒
3. **动作调整**(根据动作复杂度增加时长):
- 无动作/静态:+0秒
- 简单动作(表情、转身、拿物品):+0-1秒
- 一般动作(走动、开门、坐下):+1-2秒
- 复杂动作(打斗、追逐、大幅度移动):+2-4秒
- 环境展示(全景扫描、氛围营造):+2-5秒
4. **最终时长** = 基础时长 + 对话调整 + 动作调整确保结果在4-12秒范围内
**示例**
- "陈峥转身离开"简单动作无对话5 + 0 + 1 = 6秒
- "李芳:\"你要去哪里?\""短对话无动作4 + 2 + 0 = 6秒
- "陈峥推开房门,李芳:\"终于找到你了,这些年你去哪了?\""(一般动作+中等对话6 + 3 + 2 = 11秒
- "两人在雨中激烈搏斗,陈峥:\"住手!\""(复杂动作+短对话6 + 2 + 4 = 12秒
**重要**:准确估算每个镜头时长,所有分镜时长之和将作为剧集总时长
**特别要求**
- **【极其重要】必须100%%完整拆解整个剧本,不得省略、跳过、压缩任何剧情内容**
- **从剧本第一个字到最后一个字,逐句逐段转换为分镜**
- **每个对话、每个动作、每个场景转换都必须有对应的分镜**
- 剧本越长分镜数量越多短剧本15-30个中等剧本30-60个长剧本60-100个甚至更多
- **宁可分镜多,也不要遗漏剧情**:一个长场景可拆分为多个连续分镜
- 每个镜头只描述一个主要动作
- 区分主镜is_primary: true和链接镜is_primary: false
- 确保情绪节奏有变化
- **duration字段至关重要**:准确估算每个镜头时长,这将用于计算整集时长
- 严格按照JSON格式输出
**【禁止行为】**
- ❌ 禁止用一个镜头概括多个场景
- ❌ 禁止跳过任何对话或独白
- ❌ 禁止省略剧情发展过程
- ❌ 禁止合并本应分开的镜头
- ✅ 正确做法:剧本有多少内容,就拆解出对应数量的分镜,确保观众看完所有分镜能完整了解剧情
**【关键】场景描述详细度要求**(这些描述将直接用于视频生成模型):
1. **时间(time)字段**必须包含≥15字的详细描述
- ✓ 好例子:"深夜22:30·月光从破窗斜射入仓库在地面积水中形成银白色反光墙角昏暗不清"
- ✗ 差例子:"深夜"
2. **地点(location)字段**必须包含≥20字的详细场景描述
- ✓ 好例子:"废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱和渔网,空气中弥漫潮湿霉味"
- ✗ 差例子:"仓库"
3. **动作(action)字段**必须包含≥25字的详细动作描述包括肢体细节和表情
- ✓ 好例子:"陈峥弯腰双手握住撬棍用力撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水从额头滑落脸颊,呼吸急促"
- ✗ 差例子:"陈峥打开保险箱"
4. **结果(result)字段**必须包含≥25字的详细视觉结果描述
- ✓ 好例子:"保险箱门突然弹开发出刺耳金属声,扬起灰尘在手电筒光束中飘散,箱内空无一物只有几张发黄的旧报纸,陈峥表情从期待转为震惊和失望,瞳孔放大"
- ✗ 差例子:"门打开了"
5. **氛围(atmosphere)字段**必须包含≥20字的环境氛围描述包括光线、色调、声音
- ✓ 好例子:"昏暗冷色调·青灰色为主,只有手电筒光束在黑暗中晃动,远处传来海浪拍打码头的沉闷声,整体氛围压抑沉重"
- ✗ 差例子:"昏暗"
**描述原则**
- 所有描述性字段要像为盲人讲述画面一样详细
- 包含感官细节:视觉、听觉、触觉、嗅觉
- 描述光线、色彩、质感、动态
- 为视频生成AI提供足够的画面构建信息
- 避免抽象词汇,使用具象的视觉化描述`, characterList, sceneList, scriptContent)
// 调用AI服务生成
text, err := s.aiService.GenerateText(prompt, "")
if err != nil {
s.log.Errorw("Failed to generate storyboard", "error", err)
return nil, fmt.Errorf("生成分镜头失败: %w", err)
}
// 解析JSON结果
var result GenerateStoryboardResult
if err := utils.SafeParseAIJSON(text, &result); err != nil {
s.log.Errorw("Failed to parse storyboard JSON", "error", err, "response", text[:min(500, len(text))])
return nil, fmt.Errorf("解析分镜头结果失败: %w", err)
}
result.Total = len(result.Storyboards)
// 计算总时长(所有分镜时长之和)
totalDuration := 0
for _, sb := range result.Storyboards {
totalDuration += sb.Duration
}
s.log.Infow("Storyboard generated",
"episode_id", episodeID,
"count", result.Total,
"total_duration_seconds", totalDuration)
// 保存分镜头到数据库
if err := s.saveStoryboards(episodeID, result.Storyboards); err != nil {
s.log.Errorw("Failed to save storyboards", "error", err)
return nil, fmt.Errorf("保存分镜头失败: %w", err)
}
// 更新剧集时长(秒转分钟,向上取整)
durationMinutes := (totalDuration + 59) / 60
if err := s.db.Model(&models.Episode{}).Where("id = ?", episodeID).Update("duration", durationMinutes).Error; err != nil {
s.log.Errorw("Failed to update episode duration", "error", err)
// 不中断流程,只记录错误
} else {
s.log.Infow("Episode duration updated",
"episode_id", episodeID,
"duration_seconds", totalDuration,
"duration_minutes", durationMinutes)
}
return &result, nil
}
// generateImagePrompt 生成专门用于图片生成的提示词(首帧静态画面)
func (s *StoryboardService) generateImagePrompt(sb Storyboard) string {
var parts []string
// 1. 完整的场景背景描述
if sb.Location != "" {
locationDesc := sb.Location
if sb.Time != "" {
locationDesc += ", " + sb.Time
}
parts = append(parts, locationDesc)
}
// 2. 角色初始静态姿态(去除动作过程,只保留起始状态)
if sb.Action != "" {
initialPose := extractInitialPose(sb.Action)
if initialPose != "" {
parts = append(parts, initialPose)
}
}
// 3. 情绪氛围
if sb.Emotion != "" {
parts = append(parts, sb.Emotion)
}
// 4. 动漫风格
parts = append(parts, "anime style, first frame")
if len(parts) > 0 {
return strings.Join(parts, ", ")
}
return "anime scene"
}
// extractInitialPose 提取初始静态姿态(去除动作过程)
func extractInitialPose(action string) string {
// 去除动作过程关键词,保留初始状态描述
processWords := []string{
"然后", "接着", "接下来", "随后", "紧接着",
"向下", "向上", "向前", "向后", "向左", "向右",
"开始", "继续", "逐渐", "慢慢", "快速", "突然", "猛然",
}
result := action
for _, word := range processWords {
if idx := strings.Index(result, word); idx > 0 {
// 在动作过程词之前截断
result = result[:idx]
break
}
}
// 清理末尾标点
result = strings.TrimRight(result, ",。,. ")
return strings.TrimSpace(result)
}
// extractSimpleLocation 提取简化的场景地点(去除详细描述)
func extractSimpleLocation(location string) string {
// 在"·"符号处截断,只保留主场景名称
if idx := strings.Index(location, "·"); idx > 0 {
return strings.TrimSpace(location[:idx])
}
// 如果有逗号,只保留第一部分
if idx := strings.Index(location, ""); idx > 0 {
return strings.TrimSpace(location[:idx])
}
if idx := strings.Index(location, ","); idx > 0 {
return strings.TrimSpace(location[:idx])
}
// 限制长度不超过15个字符
maxLen := 15
if len(location) > maxLen {
return strings.TrimSpace(location[:maxLen])
}
return strings.TrimSpace(location)
}
// extractSimplePose 提取简单的核心姿态关键词不超过10个字
func extractSimplePose(action string) string {
// 只提取前面最多10个字符作为核心姿态
runes := []rune(action)
maxLen := 10
if len(runes) > maxLen {
// 在标点符号处截断
truncated := runes[:maxLen]
for i := maxLen - 1; i >= 0; i-- {
if truncated[i] == '' || truncated[i] == '。' || truncated[i] == ',' || truncated[i] == '.' {
truncated = runes[:i]
break
}
}
return strings.TrimSpace(string(truncated))
}
return strings.TrimSpace(action)
}
// extractFirstFramePose 从动作描述中提取首帧静态姿态
func extractFirstFramePose(action string) string {
// 去除表示动作过程的关键词,保留初始状态
processWords := []string{
"然后", "接着", "向下", "向前", "走向", "冲向", "转身",
"开始", "继续", "逐渐", "慢慢", "快速", "突然",
}
pose := action
for _, word := range processWords {
// 简单处理:在这些词之前截断
if idx := strings.Index(pose, word); idx > 0 {
pose = pose[:idx]
break
}
}
// 清理末尾标点
pose = strings.TrimRight(pose, ",。,.")
return strings.TrimSpace(pose)
}
// extractCompositionType 从镜头类型中提取构图类型(去除运镜)
func extractCompositionType(shotType string) string {
// 去除运镜相关描述
cameraMovements := []string{
"晃动", "摇晃", "推进", "拉远", "跟随", "环绕",
"运镜", "摄影", "移动", "旋转",
}
comp := shotType
for _, movement := range cameraMovements {
comp = strings.ReplaceAll(comp, movement, "")
}
// 清理多余的标点和空格
comp = strings.ReplaceAll(comp, "··", "·")
comp = strings.ReplaceAll(comp, "·", " ")
comp = strings.TrimSpace(comp)
return comp
}
// generateVideoPrompt 生成专门用于视频生成的提示词(包含运镜和动态元素)
func (s *StoryboardService) generateVideoPrompt(sb Storyboard) string {
var parts []string
// 1. 人物动作
if sb.Action != "" {
parts = append(parts, fmt.Sprintf("Action: %s", sb.Action))
}
// 2. 对话
if sb.Dialogue != "" {
parts = append(parts, fmt.Sprintf("Dialogue: %s", sb.Dialogue))
}
// 3. 镜头运动(视频特有)
if sb.Movement != "" {
parts = append(parts, fmt.Sprintf("Camera movement: %s", sb.Movement))
}
// 4. 镜头类型和角度
if sb.ShotType != "" {
parts = append(parts, fmt.Sprintf("Shot type: %s", sb.ShotType))
}
if sb.Angle != "" {
parts = append(parts, fmt.Sprintf("Camera angle: %s", sb.Angle))
}
// 5. 场景环境
if sb.Location != "" {
locationDesc := sb.Location
if sb.Time != "" {
locationDesc += ", " + sb.Time
}
parts = append(parts, fmt.Sprintf("Scene: %s", locationDesc))
}
// 6. 环境氛围
if sb.Atmosphere != "" {
parts = append(parts, fmt.Sprintf("Atmosphere: %s", sb.Atmosphere))
}
// 7. 情绪和结果
if sb.Emotion != "" {
parts = append(parts, fmt.Sprintf("Mood: %s", sb.Emotion))
}
if sb.Result != "" {
parts = append(parts, fmt.Sprintf("Result: %s", sb.Result))
}
// 8. 音频元素
if sb.BgmPrompt != "" {
parts = append(parts, fmt.Sprintf("BGM: %s", sb.BgmPrompt))
}
if sb.SoundEffect != "" {
parts = append(parts, fmt.Sprintf("Sound effects: %s", sb.SoundEffect))
}
// 9. 视频风格要求
parts = append(parts, "Style: cinematic anime style, smooth camera motion, natural character movement")
if len(parts) > 0 {
return strings.Join(parts, ". ")
}
return "Anime style video scene"
}
func (s *StoryboardService) saveStoryboards(episodeID string, storyboards []Storyboard) error {
// 开启事务
return s.db.Transaction(func(tx *gorm.DB) error {
// 获取该剧集所有的分镜ID
var storyboardIDs []uint
if err := tx.Model(&models.Storyboard{}).
Where("episode_id = ?", episodeID).
Pluck("id", &storyboardIDs).Error; err != nil {
return err
}
// 如果有分镜先清理关联的image_generations的storyboard_id
if len(storyboardIDs) > 0 {
if err := tx.Model(&models.ImageGeneration{}).
Where("storyboard_id IN ?", storyboardIDs).
Update("storyboard_id", nil).Error; err != nil {
return err
}
}
// 删除该剧集已有的分镜头
if err := tx.Where("episode_id = ?", episodeID).Delete(&models.Storyboard{}).Error; err != nil {
return err
}
// 注意:不删除背景,因为背景是在分镜拆解前就提取好的
// AI会直接返回scene_id不需要在这里做字符串匹配
// 保存新的分镜头
for _, sb := range storyboards {
// 构建描述信息,包含对话
description := fmt.Sprintf("【镜头类型】%s\n【运镜】%s\n【动作】%s\n【对话】%s\n【结果】%s\n【情绪】%s",
sb.ShotType, sb.Movement, sb.Action, sb.Dialogue, sb.Result, sb.Emotion)
// 生成两种专用提示词
imagePrompt := s.generateImagePrompt(sb) // 专用于图片生成
videoPrompt := s.generateVideoPrompt(sb) // 专用于视频生成
// 处理 dialogue 字段
var dialoguePtr *string
if sb.Dialogue != "" {
dialoguePtr = &sb.Dialogue
}
// 使用AI直接返回的SceneID
if sb.SceneID != nil {
s.log.Infow("Background ID from AI",
"shot_number", sb.ShotNumber,
"scene_id", *sb.SceneID)
}
epID, _ := strconv.ParseUint(episodeID, 10, 32)
// 处理 title 字段
var titlePtr *string
if sb.Title != "" {
titlePtr = &sb.Title
}
// 处理shot_type、angle、movement字段
var shotTypePtr, anglePtr, movementPtr *string
if sb.ShotType != "" {
shotTypePtr = &sb.ShotType
}
if sb.Angle != "" {
anglePtr = &sb.Angle
}
if sb.Movement != "" {
movementPtr = &sb.Movement
}
// 处理bgm_prompt、sound_effect字段
var bgmPromptPtr, soundEffectPtr *string
if sb.BgmPrompt != "" {
bgmPromptPtr = &sb.BgmPrompt
}
if sb.SoundEffect != "" {
soundEffectPtr = &sb.SoundEffect
}
// 处理result、atmosphere字段
var resultPtr, atmospherePtr *string
if sb.Result != "" {
resultPtr = &sb.Result
}
if sb.Atmosphere != "" {
atmospherePtr = &sb.Atmosphere
}
scene := models.Storyboard{
EpisodeID: uint(epID),
SceneID: sb.SceneID,
StoryboardNumber: sb.ShotNumber,
Title: titlePtr,
Location: &sb.Location,
Time: &sb.Time,
ShotType: shotTypePtr,
Angle: anglePtr,
Movement: movementPtr,
Description: &description,
Action: &sb.Action,
Result: resultPtr,
Atmosphere: atmospherePtr,
Dialogue: dialoguePtr,
ImagePrompt: &imagePrompt,
VideoPrompt: &videoPrompt,
BgmPrompt: bgmPromptPtr,
SoundEffect: soundEffectPtr,
Duration: sb.Duration,
}
if err := tx.Create(&scene).Error; err != nil {
s.log.Errorw("Failed to create scene", "error", err, "shot_number", sb.ShotNumber)
return err
}
// 关联角色
if len(sb.Characters) > 0 {
var characters []models.Character
if err := tx.Where("id IN ?", sb.Characters).Find(&characters).Error; err != nil {
s.log.Warnw("Failed to load characters for association", "error", err, "character_ids", sb.Characters)
} else if len(characters) > 0 {
if err := tx.Model(&scene).Association("Characters").Append(characters); err != nil {
s.log.Warnw("Failed to associate characters", "error", err, "shot_number", sb.ShotNumber)
} else {
s.log.Infow("Characters associated successfully",
"shot_number", sb.ShotNumber,
"character_ids", sb.Characters,
"count", len(characters))
}
}
}
}
s.log.Infow("Storyboards saved successfully", "episode_id", episodeID, "count", len(storyboards))
return nil
})
}
// UpdateStoryboardCharacters 更新分镜的角色关联
func (s *StoryboardService) UpdateStoryboardCharacters(storyboardID string, characterIDs []uint) error {
// 查找分镜
var storyboard models.Storyboard
if err := s.db.First(&storyboard, storyboardID).Error; err != nil {
return fmt.Errorf("storyboard not found: %w", err)
}
// 清除现有的角色关联
if err := s.db.Model(&storyboard).Association("Characters").Clear(); err != nil {
return fmt.Errorf("failed to clear characters: %w", err)
}
// 如果有新的角色ID加载并关联
if len(characterIDs) > 0 {
var characters []models.Character
if err := s.db.Where("id IN ?", characterIDs).Find(&characters).Error; err != nil {
return fmt.Errorf("failed to find characters: %w", err)
}
if err := s.db.Model(&storyboard).Association("Characters").Append(characters); err != nil {
return fmt.Errorf("failed to associate characters: %w", err)
}
}
s.log.Infow("Storyboard characters updated", "storyboard_id", storyboardID, "character_count", len(characterIDs))
return nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}