This commit is contained in:
2026-07-16 14:09:58 +08:00
parent a89e6fdd5c
commit d6c0cd534b
8 changed files with 232 additions and 117 deletions
+6 -1
View File
@@ -8,4 +8,9 @@ server:
name: video
workerId: 1
clientMaxBodySize: 209715200 # 200MB 上传文件限制
requestTimeout: 180 # HTTP请求超时(秒),默认60秒,改为3分钟以支持AI生成脚本
requestTimeout: 300 # HTTP请求超时(秒),默认60秒,改为3分钟以支持AI生成脚本
# AI模型调用配置
chat:
timeout: 300 # 对话模型API请求超时时间(秒)
max_retries: 3 # 请求失败最大重试次数
+21 -1
View File
@@ -67,4 +67,24 @@
## 剧本生成
你是一位专业的短剧编剧。请根据剧情描述和可用演员、场景、道具,创作一份详细的单集剧本。创作时严格以剧情描述为核心,不要自行编造与剧情描述无关的情节。
你是一位专业的短剧编剧。请根据剧情描述和可用演员、场景、道具,创作一份详细的单集剧本。
### 核心原则:剧情描述是内容下限,不是上限
- **保留所有细节**:剧情描述中出现的每一个具体动作、表情变化、对话互动、环境细节都必须完整保留在对应的镜头中,不能省略、概括或泛化(例如"百姓指指点点"是泛化,原文写了老汉瞪眼、妇人交头接耳、小孩摸轮胎被拽回,就必须把这些具体细节写进镜头)。
- **丰富而非删减**:在保留全部原有细节的基础上,可以增加合理的微观动作、反应表情、环境互动来让每个镜头更丰满。不要删除任何已有内容来"精简"。
- **禁止概括**:如果你发现自己把一个具体的动作写成了概括性的描述,说明你在删减。请回到剧情描述中把具体细节还原出来。
### 旁白与主台词区分
每个镜头必须严格区分以下两种内容:
- **旁白(narration)**:画外音解说,以第三人称描述场景背景、角色状态、时间地点等。旁白不是角色说的话,而是解说性的叙述文字。
- **主台词(dialogue)**:角色在画面中亲口说出的对白和台词。如果有多个角色对话,请标注角色名。
- 短剧需要同时有旁白解说和角色对白来推动剧情,两者缺一不可。
### 节奏要求
短剧节奏必须紧凑明快,遵循以下原则:
1. **快速推进**:每1-3秒内必须有新的情节信息、动作、对白或事件转折,不能让观众感到内容稀疏。
2. **高事件密度**:将剧情描述中的情节密集地分配到时间线上,确保每一秒都有实质内容,每个镜头必须有明确的情节日地。
3. **避免松散**:镜头切换要频繁,单个镜头不宜过长。如果一个镜头超过8秒还没有新信息出现,说明太松散,请拆分或加速节奏。
4. **拒绝空镜头**:不要为了凑时长而加入无情节推进作用的过渡性镜头,每一帧都要服务于叙事。
BIN
View File
Binary file not shown.
+21 -8
View File
@@ -21,6 +21,7 @@ type ModelConfig struct {
MaxTokens int // 最大Token数
Temperature float32 // 温度参数
Timeout time.Duration // HTTP请求超时(0表示默认)
MaxRetries int // 最大重试次数(0表示默认3次)
}
// CallChatModel 调用大模型聊天接口(OpenAI 兼容格式)
@@ -40,7 +41,7 @@ func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*Ch
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 180 * time.Second
timeout = 300 * time.Second
}
body, err := buildReqBody(cfg.ModelName, req)
@@ -49,10 +50,14 @@ func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*Ch
}
url := trimSlashes(cfg.BaseURL) + "/v1/chat/completions"
g.Log().Infof(ctx, "ChatAPI 开始调用 model=%s timeout=%v max_retries=3 body_size=%d", cfg.ModelName, timeout, len(body))
var lastErr error
maxRetries := 3
maxRetries := cfg.MaxRetries
if maxRetries <= 0 {
maxRetries = 3
}
g.Log().Infof(ctx, "ChatAPI 开始调用 model=%s timeout=%v max_retries=%d body_size=%d", cfg.ModelName, timeout, maxRetries, len(body))
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
wait := time.Duration(1<<(attempt-1)) * time.Second
@@ -68,6 +73,7 @@ func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*Ch
if doErr == nil {
g.Log().Infof(ctx, "ChatAPI 调用成功 url=%s tool_calls=%d content_len=%d",
url, len(result.ToolCalls), len(result.Content))
g.Log().Printf(ctx, "ChatAPI 返回内容:\n%s", result.Content)
return result, nil
}
@@ -117,7 +123,7 @@ func doChatRequest(ctx context.Context, url, apiKey string, body []byte, timeout
g.Log().Infof(ctx, "ChatAPI 响应完成 status=%d body_len=%d elapsed=%v",
resp.StatusCode, len(respBody), elapsed)
return parseRespBody(respBody)
return parseRespBody(ctx, respBody)
}
// ==================== 内部实现 ====================
@@ -160,8 +166,9 @@ type apiRespBody struct {
}
type apiChoice struct {
Index int `json:"index"`
Message apiRespMsg `json:"message"`
Index int `json:"index"`
Message apiRespMsg `json:"message"`
FinishReason string `json:"finish_reason"`
}
// apiRespMsg 响应消息体(arguments 使用 json.RawMessage 兼容对象和字符串)
@@ -230,7 +237,7 @@ func toAPIMessages(msgs []*ChatMessage) []apiMessage {
om.ToolCalls = make([]apiToolCall, 0, len(m.ToolCalls))
for _, tc := range m.ToolCalls {
args := tc.Arguments
if args == "" {
if args == "" || !json.Valid([]byte(args)) {
args = "{}"
}
om.ToolCalls = append(om.ToolCalls, apiToolCall{
@@ -248,7 +255,7 @@ func toAPIMessages(msgs []*ChatMessage) []apiMessage {
return out
}
func parseRespBody(data []byte) (*ChatResponse, error) {
func parseRespBody(ctx context.Context, data []byte) (*ChatResponse, error) {
var resp apiRespBody
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("解析响应失败: %s", string(data))
@@ -262,6 +269,12 @@ func parseRespBody(data []byte) (*ChatResponse, error) {
msg := resp.Choices[0].Message
cr := &ChatResponse{Content: msg.Content}
// 检测 finish_reason 是否为 length(被 max_tokens 截断)
if resp.Choices[0].FinishReason == "length" {
g.Log().Warningf(ctx, "ChatAPI 响应被截断(finish_reason=length), 当前content_len=%d, 请考虑增大max_tokens", len(msg.Content))
}
if len(msg.ToolCalls) > 0 {
cr.ToolCalls = make([]*ToolCall, 0, len(msg.ToolCalls))
for _, tc := range msg.ToolCalls {
+15 -7
View File
@@ -9,13 +9,15 @@ import (
// Shot 单集剧本中的单个镜头
type Shot struct {
Index int `json:"index"`
StartTime string `json:"startTime"` // "MM:SS"
EndTime string `json:"endTime"` // "MM:SS"
Event string `json:"event"` // 事件描述
CameraMovement string `json:"cameraMovement"` // 运镜描述
Characters []string `json:"characters"` // 出演人物(演员名列表
Scene string `json:"scene"` // 场景(场景名)
Props []string `json:"props"` // 道具(道具名列表)
StartTime string `json:"startTime"` // "MM:SS"
EndTime string `json:"endTime"` // "MM:SS"
Event string `json:"event"` // 事件描述/动作描写
Narration string `json:"narration,omitempty"` // 旁白(画外音解说,不是角色说的话)
Dialogue string `json:"dialogue,omitempty"` // 主台词(角色亲口说的对白
CameraMovement string `json:"cameraMovement"` // 运镜描述
Characters []string `json:"characters"` // 出演人物(演员名列表)
Scene string `json:"scene"` // 场景(场景名)
Props []string `json:"props"` // 道具(道具名列表)
}
// ShotsToText 将镜头数组转回纯文本格式,供视频生成流程(ReAct Agent)使用
@@ -26,6 +28,12 @@ func ShotsToText(shots []Shot) string {
}
for _, s := range shots {
fmt.Fprintf(&b, "【镜头%d】(%s-%s\n", s.Index, s.StartTime, s.EndTime)
if s.Narration != "" {
fmt.Fprintf(&b, "旁白:%s\n", s.Narration)
}
if s.Dialogue != "" {
fmt.Fprintf(&b, "台词:%s\n", s.Dialogue)
}
if s.Event != "" {
fmt.Fprintf(&b, "事件:%s\n", s.Event)
}
+155 -97
View File
@@ -389,66 +389,72 @@ func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama,
}
totalSegs := len(segDurs)
// 调用 Agent
result, err := s.generateSegment(ctx, d, ep, segIdx, segDur, segStartTime, totalSegs, modelCfg, feedback, genCtx)
if err != nil {
return err
}
segOutput := model.ParseAgentOutput(result, segIdx)
if len(segOutput.Scenes) == 0 {
g.Log().Warningf(ctx, "第%d集第%d段 Agent 未生成场景数据,请检查输入", ep.Index, segIdx+1)
}
// 从 Agent 原始输出中提取图片数据,回填到 segOutput
images := model.ExtractAgentImages(result)
for i := range segOutput.Characters {
if b64, ok := images.CharacterImages[segOutput.Characters[i].Name]; ok {
segOutput.Characters[i].ImageBase64 = b64
// JSON 镜头脚本跳过 Agent,直接构建场景描述提交视频模型
var segOutput *model.SegmentOutput
if domain.IsShotsJSON(ep.Script) && feedback == "" {
segOutput = buildSegOutputFromShots(ep.Script, segIdx, segStartTime, segDur)
g.Log().Infof(ctx, "第%d集第%d段 JSON镜头直接提交: %d个镜头", ep.Index, segIdx+1, len(segOutput.Scenes))
} else {
result, err := s.generateSegment(ctx, d, ep, segIdx, segDur, segStartTime, totalSegs, modelCfg, feedback, genCtx)
if err != nil {
return err
}
}
for i := range segOutput.Scenes {
if i < len(images.SceneImages) {
segOutput.Scenes[i].ImageBase64 = images.SceneImages[i].Base64
segOutput = model.ParseAgentOutput(result, segIdx)
if len(segOutput.Scenes) == 0 {
g.Log().Warningf(ctx, "第%d集第%d段 Agent 未生成场景数据,请检查输入", ep.Index, segIdx+1)
}
}
// 重新加载演员列表(包含新插入的形象路径)
characters, _, _ = dao.Character.ListPageByDrama(ctx, d.Id, 1, -1)
// 从 Agent 原始输出中提取图片数据
images := model.ExtractAgentImages(result)
for i := range segOutput.Characters {
if b64, ok := images.CharacterImages[segOutput.Characters[i].Name]; ok {
segOutput.Characters[i].ImageBase64 = b64
}
}
for i := range segOutput.Scenes {
if i < len(images.SceneImages) {
segOutput.Scenes[i].ImageBase64 = images.SceneImages[i].Base64
}
}
// 从数据库已有形象回填 base64(优先于 Agent 生成的临时图片)
if len(characters) > 0 {
charMap := make(map[string]string)
for _, c := range characters {
if c.PortraitPath != "" {
if b64, err := imageFileToBase64(c.PortraitPath); err == nil {
charMap[c.Name] = b64
// 重新加载演员列表
characters, _, _ = dao.Character.ListPageByDrama(ctx, d.Id, 1, -1)
// 从数据库已有形象回填 base64
if len(characters) > 0 {
charMap := make(map[string]string)
for _, c := range characters {
if c.PortraitPath != "" {
if b64, err := imageFileToBase64(c.PortraitPath); err == nil {
charMap[c.Name] = b64
}
}
}
for i := range segOutput.Characters {
name := segOutput.Characters[i].Name
if img, ok := charMap[name]; ok {
segOutput.Characters[i].ImageBase64 = img
}
}
}
for i := range segOutput.Characters {
name := segOutput.Characters[i].Name
if img, ok := charMap[name]; ok {
segOutput.Characters[i].ImageBase64 = img
}
}
}
// 保存场景图片到 workspace
dramaTitle := d.Title
for i := range segOutput.Scenes {
scene := &segOutput.Scenes[i]
if scene.ImageBase64 != "" {
if path, err := s.saveBase64Image(ctx, dramaTitle, scene.ImageBase64, "场景",
fmt.Sprintf("seg_%d_scene_%d.png", segIdx, i)); err == nil {
scene.ImagePath = path
scene.ImageBase64 = "" // 清除 base64
} else {
g.Log().Warningf(ctx, "保存场景图片失败: %v", err)
// 保存场景图片到 workspace
dramaTitle := d.Title
for i := range segOutput.Scenes {
scene := &segOutput.Scenes[i]
if scene.ImageBase64 != "" {
if path, err := s.saveBase64Image(ctx, dramaTitle, scene.ImageBase64, "场景",
fmt.Sprintf("seg_%d_scene_%d.png", segIdx, i)); err == nil {
scene.ImagePath = path
scene.ImageBase64 = ""
} else {
g.Log().Warningf(ctx, "保存场景图片失败: %v", err)
}
}
}
}
// 读取 task 中已有的 NumSegments
// 读取 task 中已有的 NumSegments
task, _ := dao.GenerationTask.GetOne(ctx, taskId)
numSegments := 1
@@ -548,6 +554,65 @@ func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama,
return nil
}
// buildSegOutputFromShots 从 JSON 镜头数组直接构建 SegmentOutput(跳过 Agent
// 将本段所有镜头合并为一段连贯的场景描述,去掉"旁白/台词/事件"标记,适合视频模型理解
func buildSegOutputFromShots(script string, segIdx, segStartTime, segDur int) *model.SegmentOutput {
var allShots []domain.Shot
if err := json.Unmarshal([]byte(script), &allShots); err != nil || len(allShots) == 0 {
return &model.SegmentOutput{Index: segIdx, TextOutput: script}
}
segEndTime := segStartTime + segDur
var segShots []domain.Shot
charSet := make(map[string]bool)
for _, sh := range allShots {
shStart := parseMMSSToSeconds(sh.StartTime)
shEnd := parseMMSSToSeconds(sh.EndTime)
if shEnd <= segStartTime || shStart >= segEndTime {
continue
}
segShots = append(segShots, sh)
for _, c := range sh.Characters {
if c != "" {
charSet[c] = true
}
}
}
var chars []model.SegmentCharacter
for name := range charSet {
chars = append(chars, model.SegmentCharacter{Name: name})
}
// 将所有镜头合并为一段连贯描述(去掉旁白/台词/事件标记,自然语言拼接)
descParts := make([]string, 0, len(segShots)*2)
for _, sh := range segShots {
// 旁白直接作为叙述
if sh.Narration != "" {
descParts = append(descParts, sh.Narration)
}
// 事件描述
if sh.Event != "" {
descParts = append(descParts, sh.Event)
}
// 对话以自然方式嵌入
if sh.Dialogue != "" {
descParts = append(descParts, sh.Dialogue)
}
}
fullDesc := strings.Join(descParts, "")
return &model.SegmentOutput{
Index: segIdx,
Scenes: []model.SegmentScene{{
Index: 0,
Description: fullDesc,
Duration: segDur,
}},
Characters: chars,
}
}
// generateSegment 调用 Agent 生成一段内容
func (s *dramaService) generateSegment(ctx context.Context, d *entity.Drama, ep *entity.Episode,
segIdx, segDur, segStartTime, totalSegs int, modelCfg *entity.ModelConfig, feedback string,
@@ -641,7 +706,7 @@ func (s *dramaService) buildSegPrompt(ctx context.Context, d *entity.Drama, ep *
- 每个演员应保持形象一致,参考形象文件路径中的图片。
- 输出 JSON 必须包含完整的人物定义(characters)和场景定义(scenes)。
- **参考图数量约束:本段中所有演员形象+场景图片+道具图片的合计参考图数量不得超过%d张(首帧已占用1个参考位,剩余%d个参考位供分配)。请合理规划,确保合计数量不超出此限制。**
- **内容密度要求:每秒钟的视频内容必须有足够的视觉信息填充。请确保场景描述(description)细分到位,包含角色的具体动作、表情变化、镜头运动、环境互动等,避免"两个人对话""走过街道"这样过于概括的描述。一段%d秒的视频应该包含流畅的情节推进,不能出现长时间静止或内容稀疏的画面。**
- **节奏与密度要求:短剧节奏必须紧凑明快,每3-8秒内必须有新的情节推进、动作、对白或事件转折,避免拖沓松散。请确保场景描述(description)细分到位,包含角色的具体动作、表情变化、镜头运动、环境互动等,避免"两个人对话""走过街道"这样过于概括的描述。一段%d秒的视频应该包含流畅的情节推进,不能出现长时间静止或内容稀疏的画面。镜头切换要频繁,每个镜头必须有明确的情节日地,删除所有无信息量的过渡性空镜头。**
- **内容合规要求:场景描述(description)和台词(lines)将提交给第三方视频生成API,该API有自动化内容审查机制。请避免使用以下可能触发审查的词汇和表达:(1)军事敏感词汇如"军装""军旗""军功章""军人"等,建议替换为"制服""旗帜""荣誉勋章""老兵"等;(2)口号式表达如"若有战召必回"等;(3)激烈动作描写如"握拳""指节发白""目光如炬""紧握""猛然"等,建议使用温和表达。请使用合规语言创作,确保内容能通过自动化审核。**%s`,
PromptService.GetSystemPrompt(ctx),
ep.Index, segIdx+1, totalSegs, segDur,
@@ -1320,7 +1385,6 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep
// 构建 prompt:将场景描述中的实体名称替换为 character1/character2/... 直接引用参考素材
refURLs := make([]string, len(namedURLs))
refExplanation := ""
if len(namedURLs) > 0 {
type nameLabel struct {
name string
@@ -1344,22 +1408,18 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep
for i, nu := range namedURLs {
refParts = append(refParts, fmt.Sprintf("character%d=%s", i+1, nu.name))
}
refExplanation = fmt.Sprintf("。角色引用说明:%s", strings.Join(refParts, "、"))
}
prompt := fmt.Sprintf("短剧《%s》第%d集第%d段:%s%s", d.Title, ep.Index, segIdx+1, sceneText, refExplanation)
prompt := sceneText
if promptMaxChars > 0 && len([]rune(prompt)) > promptMaxChars {
prefix := fmt.Sprintf("短剧《%s》第%d集第%d段:", d.Title, ep.Index, segIdx+1)
refLen := len([]rune(refExplanation))
keepSceneLen := promptMaxChars - refLen - len([]rune(prefix))
if keepSceneLen < 50 {
keepSceneLen = 50
keepLen := promptMaxChars
if keepLen < 50 {
keepLen = 50
}
sceneRunes := []rune(sceneText)
if keepSceneLen < len(sceneRunes) {
sceneText = "..." + string(sceneRunes[len(sceneRunes)-keepSceneLen+3:])
promptRunes := []rune(prompt)
if keepLen < len(promptRunes) {
prompt = "..." + string(promptRunes[len(promptRunes)-keepLen+3:])
}
prompt = prefix + sceneText + refExplanation
g.Log().Infof(ctx, "prompt超长已截断至%d字符(原%d字符)", promptMaxChars, len([]rune(prompt)))
}
@@ -1371,17 +1431,39 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep
}
}
// 从 video_schema 判断当前模型是否支持 duration 参数
effectiveDur := segDur
if modelCfg.VideoSchema != "" {
var vs map[string]any
if err := json.Unmarshal([]byte(modelCfg.VideoSchema), &vs); err == nil {
if durDef := nested(vs, "parameters", "duration"); durDef != nil {
if durMap, ok := durDef.(map[string]any); ok {
if sm, ok := durMap["supported_models"]; ok {
if models, ok := sm.([]any); ok {
found := false
for _, m := range models {
if ms, ok := m.(string); ok && ms == modelCfg.VideoModelName {
found = true
break
}
}
if !found {
effectiveDur = 0
}
}
}
}
} else {
// schema 未定义 duration 参数 → 不支持自定义时长
effectiveDur = 0
}
}
}
taskId, requestJSON, err := createVideoTask(ctx, modelCfg.VideoApiKey, modelCfg.VideoBaseUrl, modelCfg.VideoModelName,
prompt, negativePrompt, refURLs, segDur, d.Resolution, d.AspectRatio, modelCfg.VideoSchema)
prompt, negativePrompt, refURLs, effectiveDur, d.Resolution, d.AspectRatio, modelCfg.VideoSchema)
if err != nil {
errStr := strings.ToLower(err.Error())
if strings.Contains(errStr, "duration") && (strings.Contains(errStr, "not support") || strings.Contains(errStr, "not supported")) {
taskId, requestJSON, err = createVideoTask(ctx, modelCfg.VideoApiKey, modelCfg.VideoBaseUrl, modelCfg.VideoModelName,
prompt, negativePrompt, refURLs, 0, d.Resolution, d.AspectRatio, modelCfg.VideoSchema)
}
if err != nil {
return "", "", fmt.Errorf("视频合成请求失败: %w", err)
}
return "", "", fmt.Errorf("视频合成请求失败: %w", err)
}
if taskId == "" {
@@ -1403,37 +1485,11 @@ func createVideoTask(ctx context.Context, apiKey, baseURL, modelName, prompt, ne
"prompt": prompt,
},
}
body["input"].(map[string]any)["reference_urls"] = refURLs
// input.negative_prompt — 反向提示词(从 negative_prompt.md 读取,不为空时才发送)
if negativePrompt != "" {
body["input"].(map[string]any)["negative_prompt"] = negativePrompt
}
// input.reference_urls — 纯 URL 字符串数组,顺序对应 character1/character2/...
// 解析 refURLshttp/https/data: 开头的保持原样,文件路径转为 base64 data URL
for i, u := range refURLs {
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "data:") {
continue
}
if b64, err := imageFileToBase64(u); err == nil {
refURLs[i] = b64
} else {
g.Log().Warningf(ctx, "reference_urls 元素无法解析为文件或URL,跳过: %s", u)
}
}
if len(refURLs) > 0 {
body["input"].(map[string]any)["reference_urls"] = refURLs
} else {
// 无参考图时使用默认首帧
if imgData, err := os.ReadFile(DefaultFirstFramePath); err == nil {
b64 := base64.StdEncoding.EncodeToString(imgData)
body["input"].(map[string]any)["reference_urls"] = []string{"data:image/png;base64," + b64}
g.Log().Infof(ctx, "使用默认首帧图作为 reference_urls")
} else {
g.Log().Warningf(ctx, "读取默认首帧图失败: %v", err)
}
}
// 从 video_schema 读取模型特定参数(结构按 API 请求格式: input / parameters
params := map[string]any{}
if videoSchema != "" {
@@ -1485,6 +1541,8 @@ func createVideoTask(ctx context.Context, apiKey, baseURL, modelName, prompt, ne
body["parameters"] = params
payload, _ := json.Marshal(body)
promptStr, _ := body["input"].(map[string]any)["prompt"].(string)
g.Log().Infof(ctx, "视频API请求 body_size=%d prompt_len=%d prompt:\n%s", len(payload), len([]rune(promptStr)), promptStr)
req, err := http.NewRequestWithContext(ctx, "POST", baseURL, strings.NewReader(string(payload)))
if err != nil {
return "", "", fmt.Errorf("创建请求失败: %w", err)
+10 -3
View File
@@ -206,7 +206,8 @@ func (s *dramaService) GenerateScript(ctx context.Context, dramaId int64, episod
BaseURL: modelCfg.ChatBaseUrl,
MaxTokens: modelCfg.MaxTokens,
Temperature: float32(modelCfg.Temperature),
Timeout: 3 * time.Minute,
Timeout: time.Duration(g.Cfg().MustGet(ctx, "chat.timeout", 180).Int()) * time.Second,
MaxRetries: g.Cfg().MustGet(ctx, "chat.max_retries", 3).Int(),
}
messages := []*agent.ChatMessage{
@@ -233,10 +234,12 @@ func (s *dramaService) GenerateScript(ctx context.Context, dramaId int64, episod
if parseErr := json.Unmarshal([]byte(raw), &shots); parseErr == nil && len(shots) > 0 {
script = raw
g.Log().Infof(ctx, "JSON镜头脚本生成成功: 短剧=%s, 剧集=%s, 镜头数=%d", d.Title, episodeTitle, len(shots))
} else {
// 非JSON格式,作为纯文本返回
script = raw
g.Log().Infof(ctx, "文本脚本生成成功(非JSON): 短剧=%s, 剧集=%s, 长度=%d字符", d.Title, episodeTitle, len([]rune(raw)))
g.Log().Printf(ctx, "模型返回的文本:\n%s", raw)
}
return
}
@@ -287,7 +290,7 @@ func (s *dramaService) buildScriptGenUserInput(d *entity.Drama, episodeTitle, de
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("【要求】\n请根据以上剧情描述,为当前剧集创作一份详细的剧本。剧情描述是核心创作依据,请严格围绕其内容展开。\n\n输出格式:JSON数组,每个元素是一个镜头对象(shot),包含以下字段:\n- index: 镜头序号(从1开始)\n- startTime: 开始时间(格式MM:SS\n- endTime: 结束时间(格式MM:SS\n- event: 事件描述(该镜头中发生的情节概括)\n- cameraMovement: 运镜方式,从以下标准类型中选择一种:固定镜头、推、拉、摇、移、跟、升、降、旋转、晃动、航拍\n- characters: 出演人物数组,填写演员名称,如[\"张三\", \"李四\"],从「可用演员」中选择\n- scene: 场景名称,从「可用场景」中选择\n- props: 道具名称数组,如[\"剑\", \"酒杯\"],从「可用道具」中选择\n\n直接输出JSON数组,不要markdown代码块标记,不要其他任何内容。\n\n要求:\n1. 剧本必须紧扣剧情描述展开\n2. 所有镜头时长之和应等于 %d 秒(每个镜头%d-%d秒不等)\n3. 优先使用提供的演员、场景和道具,如需新增请合理创作\n4. 每个镜头的characters、scene、props字段必须从提供的可用列表中选取名称,确保与参考素材一致\n5. 对话自然流畅,情节有起伏\n6. 镜头数量建议6-15个\n", d.EpisodeDuration, d.MinShotDuration, d.MaxShotDuration))
b.WriteString(fmt.Sprintf("【要求】\n请根据以上剧情描述,为当前剧集创作一份详细的剧本。剧情描述是核心创作依据,请严格围绕其内容展开。\n\n输出格式:JSON数组,每个元素是一个镜头对象(shot),包含以下字段:\n- index: 镜头序号(从1开始)\n- startTime: 开始时间(格式MM:SS\n- endTime: 结束时间(格式MM:SS\n- narration: 旁白(画外音解说),描述场景背景、角色状态、时间地点等,第三人称叙述。不是角色说的话。\n- dialogue: 主台词(角色亲口说的对白),如角色A说「你好」,角色B说「再见」。多人对话可用A:...B:...格式。\n- event: 事件描述/动作描写(该镜头中发生的情节概括和角色动作\n- cameraMovement: 运镜方式,从以下标准类型中选择一种:固定镜头、推、拉、摇、移、跟、升、降、旋转、晃动、航拍\n- characters: 出演人物数组,填写演员名称,如[\"张三\", \"李四\"],从「可用演员」中选择\n- scene: 场景名称,从「可用场景」中选择\n- props: 道具名称数组,如[\"剑\", \"酒杯\"],从「可用道具」中选择\n\n直接输出JSON数组,不要markdown代码块标记,不要其他任何内容。\n\n要求:\n1. 剧本必须紧扣剧情描述展开\n2. 所有镜头时长之和应等于 %d 秒(每个镜头%d-%d秒不等)\n3. 优先使用提供的演员、场景和道具,如需新增请合理创作\n4. 每个镜头的characters、scene、props字段必须从提供的可用列表中选取名称,确保与参考素材一致\n5. **严格区分旁白和主台词:narration是画外音(描述场景/角色),dialogue是角色在画面中说出口的对话。不要混淆两者。短剧需要同时有旁白解说和角色对白来推动剧情。**\n6. **节奏紧凑:剧情推进要快速有力,避免拖沓。每%d-%d秒内必须有新的情节信息、动作、对白或事件转折,不能让观众感到内容稀疏。**\n7. **事件密度:将剧情描述中的情节密集地分配到时间线上,确保每一秒都有实质内容。每个镜头必须有明确的情节目的,删除所有无信息量的过渡性空镜头。**\n8. **避免松散:镜头切换要频繁,单个镜头不宜过长。如果一个镜头超过%d秒还没有新信息出现,说明太松散,请拆分或加速节奏。**\n9. **禁止删减和泛化:剧情描述中的每一个具体细节(人物的动作、表情反应、对话、环境互动等)都必须忠实地保留在对应镜头的event、narration或dialogue字段中,不能省略、概括或改写为泛化描述。例如「百姓指指点点」是泛化,必须写出具体谁做了什么表情/动作。「衙役尝了说好」是泛化,必须写出闻香→咽口水→试探咬→瞳孔放大→猛咬→竖拇指→满嘴油光这整个递进过程。如果你发现在把原文细节丢掉,请停下来把细节还原回去。**\n", d.EpisodeDuration, d.MinShotDuration, d.MaxShotDuration, d.MinShotDuration, d.MaxShotDuration, d.MaxShotDuration))
return b.String()
}
@@ -466,11 +469,14 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string,
totalDur int
}
var cur _shotGroup
var prevScene string
for _, sh := range allShots {
shDur := parseMMSSToSeconds(sh.EndTime) - parseMMSSToSeconds(sh.StartTime)
if shDur <= 0 {
shDur = 1
}
// 场景切换 → 必须分段(不同场景不能合并到同一段)
sceneChanged := len(cur.shots) > 0 && sh.Scene != prevScene
// 计算加入当前镜头后的候选组总时长和提示词长度
candShots := make([]domain.Shot, len(cur.shots)+1)
copy(candShots, cur.shots)
@@ -481,7 +487,7 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string,
exceedDur := len(cur.shots) > 0 && candDur > effectiveMax
exceedChars := promptMaxChars > 0 && len([]rune(candPrompt)) > promptMaxChars
if exceedDur || exceedChars {
if exceedDur || exceedChars || sceneChanged {
// 当前组已满,保存并开启新组
taskGroups = append(taskGroups, _taskGroup{
promptText: buildFinalPrompt(domain.ShotsToText(cur.shots)),
@@ -492,6 +498,7 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string,
cur.shots = candShots
cur.totalDur = candDur
}
prevScene = sh.Scene
}
if len(cur.shots) > 0 {
taskGroups = append(taskGroups, _taskGroup{
+4
View File
@@ -87,8 +87,10 @@ func (c *GenerationContext) buildRefIndex() error {
}
b64, err := imageFileToBase64(ch.PortraitPath)
if err != nil {
fmt.Printf("buildRefIndex 警告: 无法读取演员[%s]的形象图片: %v (path=%s)\n", ch.Name, err, ch.PortraitPath)
continue
}
if existing, ok := charIdx[ch.Name]; ok {
return fmt.Errorf("演员名冲突: '%s' (已有形象路径 %s,重复 %s)", ch.Name, existing, ch.PortraitPath)
}
@@ -106,6 +108,7 @@ func (c *GenerationContext) buildRefIndex() error {
}
b64, err := imageFileToBase64(sc.ImagePath)
if err != nil {
fmt.Printf("buildRefIndex 警告: 无法读取场景[%s]的图片: %v (path=%s)\n", sc.Name, err, sc.ImagePath)
continue
}
if _, ok := sceneIdx[sc.Name]; ok {
@@ -125,6 +128,7 @@ func (c *GenerationContext) buildRefIndex() error {
}
b64, err := imageFileToBase64(p.ImagePath)
if err != nil {
fmt.Printf("buildRefIndex 警告: 无法读取道具[%s]的图片: %v (path=%s)\n", p.Name, err, p.ImagePath)
continue
}
if _, ok := propIdx[p.Name]; ok {