742 lines
27 KiB
Go
742 lines
27 KiB
Go
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"` // 背景ID(AI直接返回,可为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
|
||
}
|