From d6c0cd534ba6bdac90020c5eb5fe0d7a4d4b1dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Thu, 16 Jul 2026 14:09:58 +0800 Subject: [PATCH] 1 --- config.yml | 7 +- prompt.md | 22 +- short_drama.db | Bin 462848 -> 462848 bytes shortdrama/agent/chat_model.go | 29 ++- shortdrama/model/domain/shot.go | 22 +- shortdrama/service/drama_service.go | 252 ++++++++++++++--------- shortdrama/service/episode_service.go | 13 +- shortdrama/service/generation_context.go | 4 + 8 files changed, 232 insertions(+), 117 deletions(-) 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 002cb2590e5cfbfb74af1130b6914edfeb6c99f5..58303b646619fe153b5f98b32ebb4e5a6f725cee 100644 GIT binary patch delta 31623 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 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 {