diff --git a/config.yml b/config.yml index e7d419b..141d216 100644 --- a/config.yml +++ b/config.yml @@ -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 # 请求失败最大重试次数 diff --git a/prompt.md b/prompt.md index 4967714..8f37f66 100644 --- a/prompt.md +++ b/prompt.md @@ -67,4 +67,24 @@ ## 剧本生成 -你是一位专业的短剧编剧。请根据剧情描述和可用演员、场景、道具,创作一份详细的单集剧本。创作时严格以剧情描述为核心,不要自行编造与剧情描述无关的情节。 +你是一位专业的短剧编剧。请根据剧情描述和可用演员、场景、道具,创作一份详细的单集剧本。 + +### 核心原则:剧情描述是内容下限,不是上限 + +- **保留所有细节**:剧情描述中出现的每一个具体动作、表情变化、对话互动、环境细节都必须完整保留在对应的镜头中,不能省略、概括或泛化(例如"百姓指指点点"是泛化,原文写了老汉瞪眼、妇人交头接耳、小孩摸轮胎被拽回,就必须把这些具体细节写进镜头)。 +- **丰富而非删减**:在保留全部原有细节的基础上,可以增加合理的微观动作、反应表情、环境互动来让每个镜头更丰满。不要删除任何已有内容来"精简"。 +- **禁止概括**:如果你发现自己把一个具体的动作写成了概括性的描述,说明你在删减。请回到剧情描述中把具体细节还原出来。 + +### 旁白与主台词区分 + +每个镜头必须严格区分以下两种内容: +- **旁白(narration)**:画外音解说,以第三人称描述场景背景、角色状态、时间地点等。旁白不是角色说的话,而是解说性的叙述文字。 +- **主台词(dialogue)**:角色在画面中亲口说出的对白和台词。如果有多个角色对话,请标注角色名。 +- 短剧需要同时有旁白解说和角色对白来推动剧情,两者缺一不可。 + +### 节奏要求 +短剧节奏必须紧凑明快,遵循以下原则: +1. **快速推进**:每1-3秒内必须有新的情节信息、动作、对白或事件转折,不能让观众感到内容稀疏。 +2. **高事件密度**:将剧情描述中的情节密集地分配到时间线上,确保每一秒都有实质内容,每个镜头必须有明确的情节日地。 +3. **避免松散**:镜头切换要频繁,单个镜头不宜过长。如果一个镜头超过8秒还没有新信息出现,说明太松散,请拆分或加速节奏。 +4. **拒绝空镜头**:不要为了凑时长而加入无情节推进作用的过渡性镜头,每一帧都要服务于叙事。 \ No newline at end of file diff --git a/short_drama.db b/short_drama.db index 002cb25..58303b6 100644 Binary files a/short_drama.db and b/short_drama.db differ diff --git a/shortdrama/agent/chat_model.go b/shortdrama/agent/chat_model.go index 463c402..72b2d65 100644 --- a/shortdrama/agent/chat_model.go +++ b/shortdrama/agent/chat_model.go @@ -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 { diff --git a/shortdrama/model/domain/shot.go b/shortdrama/model/domain/shot.go index 5990b13..e128401 100644 --- a/shortdrama/model/domain/shot.go +++ b/shortdrama/model/domain/shot.go @@ -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) } diff --git a/shortdrama/service/drama_service.go b/shortdrama/service/drama_service.go index 56ee969..94ba184 100644 --- a/shortdrama/service/drama_service.go +++ b/shortdrama/service/drama_service.go @@ -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/... - // 解析 refURLs:http/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) diff --git a/shortdrama/service/episode_service.go b/shortdrama/service/episode_service.go index c9dd201..bed0e19 100644 --- a/shortdrama/service/episode_service.go +++ b/shortdrama/service/episode_service.go @@ -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{ diff --git a/shortdrama/service/generation_context.go b/shortdrama/service/generation_context.go index 688eeeb..ea0549d 100644 --- a/shortdrama/service/generation_context.go +++ b/shortdrama/service/generation_context.go @@ -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 {