diff --git a/config.yml b/config.yml index bac188f..dec3d01 100644 --- a/config.yml +++ b/config.yml @@ -17,6 +17,12 @@ chat: timeout: 3000 # 对话模型API请求超时时间(秒) max_retries: 3 # 请求失败最大重试次数 +# 朗读速度配置(字/秒) +speech: + charPerSecond: 4 # 正常语速 + fastCharPerSecond: 5 # 情绪激动时(愤怒、紧张、兴奋、紧迫),对应台词中含大量!?或短促句式 + slowCharPerSecond: 3 # 情绪低落时(悲伤、沉思、犹豫),对应台词中含……唉等拖沓语气 + # 各内容类型的画风/镜间质量/防崩坏约束及镜头时长规则(生成脚本时注入 prompt) shotDuration: # ==================== 短剧 ==================== diff --git a/prompt.md b/prompt.md index 2d05a22..cd20d9d 100644 --- a/prompt.md +++ b/prompt.md @@ -137,7 +137,7 @@ 1. **基础动作上限2秒**:单个物理动作(跑、跳、砸、摔、瞪眼、拔刀、推门、转身、蹲下等)对应的镜头时长不得超过2秒。如果一个场景中连续发生了多个动作,必须拆分成多个镜头,每个镜头1-2秒。 -2. **对话镜头上限3秒**:纯对话或反应镜头最长3秒。 +2. **对话镜头时长由台词长度决定**:对话或反应镜头的时长必须能容纳台词和旁白配音,参考中文语速约4-5字/秒。例如一段15字的台词至少需要4秒。**本条优先级高于基础动作2秒规则**。 3. **1秒原则**:震惊、回头、对视、抬手等简单反应或微表情,直接用1秒。不要给这些内容分配2秒以上。 diff --git a/short_drama.db b/short_drama.db index 74cf91f..5b1f5e3 100644 Binary files a/short_drama.db and b/short_drama.db differ diff --git a/shortdrama/dao/model_config_dao.go b/shortdrama/dao/model_config_dao.go index ba61775..e676389 100644 --- a/shortdrama/dao/model_config_dao.go +++ b/shortdrama/dao/model_config_dao.go @@ -60,7 +60,7 @@ func init() { "price INTEGER NOT NULL DEFAULT 0,"+ "price_unit TEXT NOT NULL DEFAULT 'second',"+ "concurrency_count INTEGER NOT NULL DEFAULT 1,"+ - "first_frame_mapping TEXT NOT NULL DEFAULT '',"+ + "schema_mapping TEXT NOT NULL DEFAULT '',"+ "reference_template TEXT NOT NULL DEFAULT '',"+ "created_at DATETIME DEFAULT (datetime('now','localtime')),"+ "updated_at DATETIME DEFAULT (datetime('now','localtime'))"+ @@ -144,21 +144,37 @@ func init() { } } - // 检测缺少 first_frame_mapping 列的情况 + // 检测并迁移:first_frame_mapping -> schema_mapping if r, _ := g.DB().GetAll(ctx, "PRAGMA table_info("+consts.TableNameModelConfig+")"); r != nil { - hasCol := false + hasFirstFrame := false + hasSchemaMapping := false for _, col := range r { - if col["name"].String() == "first_frame_mapping" { - hasCol = true - break + name := col["name"].String() + if name == "first_frame_mapping" { + hasFirstFrame = true + } + if name == "schema_mapping" { + hasSchemaMapping = true } } - if !hasCol { - g.Log().Info(ctx, "检测到 model_config 缺少 first_frame_mapping 列,正在补充...") - if _, err := g.DB().Exec(ctx, "ALTER TABLE "+consts.TableNameModelConfig+" ADD COLUMN first_frame_mapping TEXT NOT NULL DEFAULT ''"); err != nil { - g.Log().Warningf(ctx, "add first_frame_mapping column failed: %v", err) + // 旧表重命名 first_frame_mapping -> schema_mapping + if hasFirstFrame && !hasSchemaMapping { + g.Log().Info(ctx, "检测到旧 first_frame_mapping 列,正在迁移到 schema_mapping...") + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+consts.TableNameModelConfig+" RENAME COLUMN first_frame_mapping TO schema_mapping"); err != nil { + g.Log().Warningf(ctx, "rename first_frame_mapping to schema_mapping failed: %v", err) } else { - g.Log().Info(ctx, "first_frame_mapping 列补充完成") + // 将旧值(纯文本路径)转为 JSON 格式 + g.DB().Exec(ctx, "UPDATE "+consts.TableNameModelConfig+" SET schema_mapping = '{}' WHERE schema_mapping != '' AND schema_mapping NOT LIKE '{'") + g.Log().Info(ctx, "first_frame_mapping 迁移到 schema_mapping 完成") + } + } + // 无任何映射列时直接添加 schema_mapping + if !hasFirstFrame && !hasSchemaMapping { + g.Log().Info(ctx, "检测到 model_config 缺少 schema_mapping 列,正在补充...") + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+consts.TableNameModelConfig+" ADD COLUMN schema_mapping TEXT NOT NULL DEFAULT ''"); err != nil { + g.Log().Warningf(ctx, "add schema_mapping column failed: %v", err) + } else { + g.Log().Info(ctx, "schema_mapping 列补充完成") } } } diff --git a/shortdrama/model/dto/model_config_dto.go b/shortdrama/model/dto/model_config_dto.go index cfd098b..ace7802 100644 --- a/shortdrama/model/dto/model_config_dto.go +++ b/shortdrama/model/dto/model_config_dto.go @@ -28,7 +28,7 @@ type SaveModelConfigReq struct { ModelType string `json:"modelType" v:"required|in:chat,video" dc:"chat=对话模型 video=视频模型"` ModelName string `json:"modelName" v:"required|min-length:1|max-length:200" dc:"模型名称"` Schema *gjson.Json `json:"schema" dc:"请求体JSON Schema"` - FirstFrameMapping string `json:"firstFrameMapping" dc:"首帧映射字段名,如 input.media"` + SchemaMapping string `json:"schemaMapping" dc:"schema映射JSON, first_frame/reference_image等为\"path?field=value&urlField=#\", min_duration/max_duration为路径"` ReferenceTemplate string `json:"referenceTemplate" dc:"参考图引用模板,如 {\"type\":\"image\",\"url\":\"%s\"}"` Price int `json:"price" v:"min:0" dc:"价格(分)"` PriceUnit string `json:"priceUnit" v:"in:second,video" dc:"价格单位(second=每秒/video=每次视频)"` diff --git a/shortdrama/model/entity/model_config.go b/shortdrama/model/entity/model_config.go index 693ff58..7749bdb 100644 --- a/shortdrama/model/entity/model_config.go +++ b/shortdrama/model/entity/model_config.go @@ -10,7 +10,7 @@ type ModelConfig struct { ModelType string `orm:"model_type" json:"modelType" dc:"chat=对话模型 video=视频模型"` ModelName string `orm:"model_name" json:"modelName" dc:"模型名称"` Schema string `orm:"schema" json:"schema" dc:"请求体JSON Schema"` - FirstFrameMapping string `orm:"first_frame_mapping" json:"firstFrameMapping" dc:"首帧映射字段名,如 input.media"` + SchemaMapping string `orm:"schema_mapping" json:"schemaMapping" dc:"schema映射JSON, first_frame/reference_image/reference_video为\"path?field=value&urlField=#\"格式, 如 {\"first_frame\":\"input.media?type=first_frame&url=#\",\"min_duration\":\"parameters.duration.min\",\"max_duration\":\"parameters.duration.max\"}"` ReferenceTemplate string `orm:"reference_template" json:"referenceTemplate" dc:"参考图引用模板,如 {\"type\":\"image\",\"url\":\"%s\"}"` Price int `orm:"price" json:"price" dc:"价格(分)"` PriceUnit string `orm:"price_unit" json:"priceUnit" dc:"价格单位(second=每秒/video=每次视频)"` diff --git a/shortdrama/service/episode_service.go b/shortdrama/service/episode_service.go index 2dcd7ab..8d20c82 100644 --- a/shortdrama/service/episode_service.go +++ b/shortdrama/service/episode_service.go @@ -244,7 +244,7 @@ func (s *episodeService) GenerateScript(ctx context.Context, dramaId int64, epis } systemPrompt := PromptService.GetScriptGenerationPrompt(ctx) - userInput := s.buildScriptGenUserInput(ctx, d, episodeTitle, description, genCtx, refTemplate) + userInput := s.buildScriptGenUserInput(ctx, d, episodeTitle, description, genCtx, refTemplate, videoModel) chatCfg := &agent.ModelConfig{ ModelName: modelCfg.ModelName, @@ -256,6 +256,10 @@ func (s *episodeService) GenerateScript(ctx context.Context, dramaId int64, epis MaxRetries: g.Cfg().MustGet(ctx, "chat.max_retries", 3).Int(), } + charPerSecond := g.Cfg().MustGet(ctx, "speech.charPerSecond", 4).Int() + fastCharPerSecond := g.Cfg().MustGet(ctx, "speech.fastCharPerSecond", 6).Int() + slowCharPerSecond := g.Cfg().MustGet(ctx, "speech.slowCharPerSecond", 3).Int() + messages := []*agent.ChatMessage{ {Role: agent.RoleSystem, Content: systemPrompt}, {Role: agent.RoleUser, Content: userInput}, @@ -283,7 +287,13 @@ func (s *episodeService) GenerateScript(ctx context.Context, dramaId int64, epis var shots []domain.Shot var parseErr error if parseErr = json.Unmarshal([]byte(raw), &shots); parseErr == nil && len(shots) > 0 { - script = raw + // 朗读时长后处理:校正镜头时间,确保台词/旁白能在分配时长内读完 + fixupShotDurationsForNarration(&shots, int(d.EpisodeDuration), charPerSecond, fastCharPerSecond, slowCharPerSecond) + if fixedJSON, e := json.Marshal(shots); e == nil { + script = string(fixedJSON) + } else { + script = raw + } g.Log().Infof(ctx, "JSON shot script generated: drama=%s, episode=%s, shots=%d", d.Title, episodeTitle, len(shots)) return } @@ -306,7 +316,13 @@ func (s *episodeService) GenerateScript(ctx context.Context, dramaId int64, epis var shots2 []domain.Shot var parseErr2 error if parseErr2 = json.Unmarshal([]byte(result2.Content), &shots2); parseErr2 == nil && len(shots2) > 0 { - script = result2.Content + // 朗读时长后处理:校正镜头时间 + fixupShotDurationsForNarration(&shots2, int(d.EpisodeDuration), charPerSecond, fastCharPerSecond, slowCharPerSecond) + if fixedJSON, e := json.Marshal(shots2); e == nil { + script = string(fixedJSON) + } else { + script = result2.Content + } g.Log().Infof(ctx, "JSON script fix succeeded: drama=%s, episode=%s, shots=%d", d.Title, episodeTitle, len(shots2)) return } @@ -322,6 +338,162 @@ func (s *episodeService) GenerateScript(ctx context.Context, dramaId int64, epis return } +// truncateDescriptionForDuration 根据单集时长限制,截断剧本中超出朗读预算的台词。 +// 按场景段落逐段检查台词总字数,从尾部移除超出预算的段落。 +// 只处理结构化剧本(含【第N场】或镜头N标记),非结构化文本直接返回原文。 +func truncateDescriptionForDuration(description string, episodeDuration int, charPerSecond int) string { + if description == "" || episodeDuration <= 0 || charPerSecond <= 0 { + return description + } + // 台词预算:单集时长 × 语速 × 0.4(约40%时间为纯朗读,考虑无声镜头和缓冲) + budget := int(float64(episodeDuration) * float64(charPerSecond) * 0.4) + if budget < 30 { + budget = 30 + } + + // 只处理包含场景或镜头标记的结构化描述 + if !strings.Contains(description, "【第") && !strings.Contains(description, "镜头") { + return description + } + + // 按场景【第N场】切分 + sceneRe := regexp.MustCompile(`(?m)^【第.+?场】`) + locs := sceneRe.FindAllStringIndex(description, -1) + if len(locs) <= 1 { + return description + } + + type segInfo struct { + text string + chars int + } + segments := make([]segInfo, 0, len(locs)) + totalChars := 0 + for i, loc := range locs { + start := loc[0] + end := len(description) + if i+1 < len(locs) { + end = locs[i+1][0] + } + seg := description[start:end] + chars := countDialogueChars(seg) + segments = append(segments, segInfo{text: seg, chars: chars}) + totalChars += chars + } + + if totalChars <= budget { + return description + } + + // 从尾部截断段落 + keepEnd := len(segments) + running := totalChars + for i := len(segments) - 1; i >= 0; i-- { + next := running - segments[i].chars + if next <= budget { + break + } + running = next + keepEnd = i + } + + var result strings.Builder + for i := 0; i < keepEnd; i++ { + if i > 0 { + result.WriteString("\n") + } + result.WriteString(strings.TrimSpace(segments[i].text)) + } + removed := len(segments) - keepEnd + result.WriteString(fmt.Sprintf("\n\n(以下 %d 段因台词总量 %d 字超预算 %d 字已截断,剩余台词 %d 字)", removed, totalChars, budget, running)) + return result.String() +} + +// countDialogueChars 统计一段文本中台词的总字数。 +// 支持两种格式: +// 1. "角色名(情绪)"或"角色名:"独占一行,后续(或同行分隔符后的)文本为台词 +// 2. "台词:内容"或"台词:内容"格式 +func countDialogueChars(text string) int { + total := 0 + lines := strings.Split(text, "\n") + for i := 0; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + + // 台词:格式 + if strings.HasPrefix(line, "台词") { + for _, sep := range []string{":", ":"} { + if idx := strings.Index(line, sep); idx >= 0 { + content := strings.TrimSpace(line[idx+len(sep):]) + total += len([]rune(content)) + break + } + } + continue + } + + // 跳过镜头/场景/天气等描述行 + if strings.HasPrefix(line, "镜头") || strings.HasPrefix(line, "场景") || strings.HasPrefix(line, "天气") || strings.HasPrefix(line, "【") { + continue + } + + // 检查是否为角色名(情绪)独占一行 → 下一行是台词 + if matched, _ := regexp.MatchString(`^[\p{Han}\w]+[((].+[))]$`, line); matched { + // 同行也可能有台词:角色名(情绪)台词内容 + inlineContent := extractAfterBracket(line) + if inlineContent != "" { + total += len([]rune(inlineContent)) + } else if i+1 < len(lines) { + // 下一行是台词正文 + nextLine := strings.TrimSpace(lines[i+1]) + if nextLine != "" && !strings.HasPrefix(nextLine, "镜头") && !strings.HasPrefix(nextLine, "【") { + total += len([]rune(nextLine)) + i++ // 跳过已处理的台词行 + } + } + continue + } + + // 角色名:台词 格式(无括号情绪) + if idx := strings.Index(line, ":"); idx >= 0 { + name := strings.TrimSpace(line[:idx]) + if len([]rune(name)) <= 4 && !strings.HasPrefix(name, "http") && !strings.Contains(name, "//") { + total += len([]rune(strings.TrimSpace(line[idx+len(":"):]))) + continue + } + } + if idx := strings.Index(line, ":"); idx >= 0 { + name := strings.TrimSpace(line[:idx]) + if len([]rune(name)) <= 4 && !strings.HasPrefix(name, "http") && !strings.Contains(name, "//") { + total += len([]rune(strings.TrimSpace(line[idx+len(":"):]))) + continue + } + } + } + return total +} + +// extractAfterBracket 从 '角色名(情绪)台词内容' 格式中提取括号后的台词 +func extractAfterBracket(line string) string { + for _, closeB := range []string{")", ")"} { + if idx := strings.Index(line, closeB); idx >= 0 { + after := strings.TrimSpace(line[idx+len(closeB):]) + if after != "" { + // 检查之后是否有:或: + for _, sep := range []string{":", ":"} { + if sepIdx := strings.Index(after, sep); sepIdx >= 0 { + return strings.TrimSpace(after[sepIdx+len(sep):]) + } + } + return after + } + } + } + return "" +} + // isStructuredDescription 检测剧情描述是否已包含结构化镜头信息 // 判断依据:包含"镜头"、"景别"、"运镜"、"开始时间"、"结束时间"、"台词"、"画外音"、"环境音" // 或形如 MM:SS-MM:SS 的时间范围 @@ -345,11 +517,22 @@ func isStructuredDescription(desc string) bool { } // buildScriptGenUserInput 构建脚本生成的用户输入提示 -func (s *episodeService) buildScriptGenUserInput(ctx context.Context, d *entity.Drama, episodeTitle, description string, genCtx *GenerationContext, refTemplate string) string { +func (s *episodeService) buildScriptGenUserInput(ctx context.Context, d *entity.Drama, episodeTitle, description string, genCtx *GenerationContext, refTemplate string, modelCfg *entity.ModelConfig) string { var b strings.Builder + speedPrompt := fmt.Sprintf("(正常%d字/秒、激动%d字/秒、低沉%d字/秒,根据台词语气选择对应语速)", + g.Cfg().MustGet(ctx, "speech.charPerSecond", 4).Int(), + g.Cfg().MustGet(ctx, "speech.fastCharPerSecond", 6).Int(), + g.Cfg().MustGet(ctx, "speech.slowCharPerSecond", 3).Int()) fmt.Fprintf(&b, "每集时长:%d秒\n\n", d.EpisodeDuration) + // 视频分段时长约束(通过 schema_mapping 从 schema 读取) + if modelCfg != nil && modelCfg.Schema != "" && modelCfg.SchemaMapping != "" { + if minDur, maxDur := schemaDurationBounds(modelCfg.Schema, modelCfg.SchemaMapping); maxDur > 0 { + fmt.Fprintf(&b, "【视频分段约束】\n完整脚本将按每段不超过%d秒(每段最少%d秒)拆分为多个独立视频生成任务。请确保每段(连续%ds的镜头组)具备叙事连贯性,包含完整的剧情节拍。段内各镜头的时长按台词和旁白的字数比例分配可用时长,台词/旁白多的镜头分配更多时间。\n\n", maxDur, minDur, maxDur) + } + } + fmt.Fprintf(&b, "【剧情描述(核心创作依据)】\n%s\n\n", description) if len(genCtx.Characters) > 0 { @@ -392,17 +575,17 @@ func (s *episodeService) buildScriptGenUserInput(ctx context.Context, d *entity. b.WriteString("\n") } - b.WriteString("【输出格式】\nJSON数组,每个元素是一个镜头对象(shot),包含以下字段:\n- index: 镜头序号(从1开始)\n- startTime: 开始时间(格式MM:SS)\n- endTime: 结束时间(格式MM:SS)\n- event: 画面描述——观众在屏幕上直接看到的一切。场景环境、角色动作、表情变化、物体位置等。**这是视频模型生成画面的唯一依据,所有画面内容必须写在这里**,严禁把画面描述放入 narration 字段。不包含旁白和台词。\n- dialogue: 主台词——角色在画面中亲口说出的对白。多人对话用「角色名:台词」格式。旁白和内心独白不属于这里。如果本镜头无人说话,留空即可。\n- narration: 旁白配音——需要配音演员念出来的解说文字。必须是**通过画面无法直接传达**的信息(如角色内心独白、故事背景交代、时间跳跃说明等)。**如果一段文字描述的是观众能直接看到的画面内容,它就不属于旁白,必须放进 event 字段。** 本镜头无旁白时留空即可。\n- ambientSound: 环境音效——背景中的声音元素,如风声、雨声、脚步声、警笛声、门铃声、人群嘈杂等。后期音效合成,不需要配音。本镜头无特殊环境音时留空即可。\n- shotSize: 景别,从以下标准类型中选择一种:远景、全景、中景、近景、特写\n- cameraMovement: 运镜方式,从以下标准类型中选择一种:固定镜头、推、拉、摇、移、跟、升、降、旋转、晃动、航拍\n- characters: 出演人物数组,填写演员名称,如[\"张三\", \"李四\"],从「可用演员」中选择\n- scene: 场景名称,从「可用场景」中选择\n- props: 道具名称数组,如[\"剑\", \"酒杯\"],从「可用道具」中选择\n\n**朗读时长约束(硬性规则)**:每个镜头的时长(endTime−startTime)必须足以容纳其台词(dialogue)和旁白(narration)的配音。参考中文语速约4-5字/秒,台词+旁白的预估朗读时间(总字数÷4)不得大于镜头时长的80%。例:一段15字的台词至少需要4秒(15÷4≈3.8),该镜头endTime-startTime应≥4秒。如果当前时长不足,必须延长endTime,并按需调整后续镜头的startTime。\n\n直接输出JSON数组,不要markdown代码块标记,不要其他任何内容。\n\n") + b.WriteString("【输出格式】\nJSON数组,每个元素是一个镜头对象(shot),包含以下字段:\n- index: 镜头序号(从1开始)\n- startTime: 开始时间(格式MM:SS)\n- endTime: 结束时间(格式MM:SS)\n- event: 画面描述——观众在屏幕上直接看到的一切。场景环境、角色动作、表情变化、物体位置等。**这是视频模型生成画面的唯一依据,所有画面内容必须写在这里**,严禁把画面描述放入 narration 字段。不包含旁白和台词。\n- dialogue: 主台词——角色在画面中亲口说出的对白。多人对话用「角色名:台词」格式。旁白和内心独白不属于这里。如果本镜头无人说话,留空即可。\n- narration: 旁白配音——需要配音演员念出来的解说文字。必须是**通过画面无法直接传达**的信息(如角色内心独白、故事背景交代、时间跳跃说明等)。**如果一段文字描述的是观众能直接看到的画面内容,它就不属于旁白,必须放进 event 字段。** 本镜头无旁白时留空即可。\n- ambientSound: 环境音效——背景中的声音元素,如风声、雨声、脚步声、警笛声、门铃声、人群嘈杂等。后期音效合成,不需要配音。本镜头无特殊环境音时留空即可。\n- shotSize: 景别,从以下标准类型中选择一种:远景、全景、中景、近景、特写\n- cameraMovement: 运镜方式,从以下标准类型中选择一种:固定镜头、推、拉、摇、移、跟、升、降、旋转、晃动、航拍\n- characters: 出演人物数组,填写演员名称,如[\"张三\", \"李四\"],从「可用演员」中选择。**推断规则:从本镜头的event(画面出现了谁)、dialogue(看\"角色名:台词\"格式中的角色名)、narration(旁白提到了谁)三个字段中提取出演人物,三者取并集。画面中没出现、没说话、旁白也没提到的人物绝不能出现在characters中**\n- scene: 场景名称,从「可用场景」中选择\n- props: 道具名称数组,如[\"剑\", \"酒杯\"],从「可用道具」中选择\n\n**朗读时长约束(硬性规则)**:每个镜头的时长(endTime−startTime)必须足以容纳其台词(dialogue)和旁白(narration)的配音。参考中文语速" + speedPrompt + ",台词+旁白的预估朗读时间(总字数÷对应语速值)不得大于镜头时长的80%。如果当前时长不足,必须延长endTime,并按需调整后续镜头的startTime。\n\n直接输出JSON数组,不要markdown代码块标记,不要其他任何内容。\n\n") // 根据剧情描述是否已有结构化信息,动态调整创作规范 if isStructuredDescription(description) { - b.WriteString("【创作规范】\n\n【重要】剧情描述已包含结构化镜头信息,执行「只补缺不创作」模式:\n\n1. 严格保留已有内容 —— 剧情描述中每个镜头已包含的字段(场景、天气、运镜、开始时间、结束时间、台词、画外音、环境音、景别、人物、道具)直接原样保留到输出 JSON 的对应字段,不做任何改写或润色。\n\n2. 只补充缺失字段 —— 检查每个镜头缺少哪些字段,仅对确实缺失的字段进行补充:\n - 缺少景别(shotSize)→ 按镜头内容从「远景/全景/中景/近景/特写」中选择合适的\n - 缺少运镜(cameraMovement)→ 按场景类型从「固定镜头/推/拉/摇/移/跟/升/降/旋转/晃动/航拍」中选择\n - 缺少台词/画外音/环境音 → 若剧情描述中已写明则原样保留,未写就留空\n - 缺少时间 → 按总时长和镜头顺序合理分配\n - scene/characters/props 缺失 → 从「可用场景/演员/道具」中选择匹配的\n\n3. 禁止新增镜头或情节 —— 严格按照剧情描述中已有的镜头分段输出,不得自行拆分、合并、增加剧情描述中没有的镜头或动作。剧情描述中有几个镜头就输出几个镜头。\n\n4. 朗读时长约束 —— 有台词(dialogue)或旁白(narration)的镜头必须遵守:\n - 镜头时长(endTime−startTime)必须足以容纳配音内容。参考中文语速约4-5字/秒。\n - 要求:endTime−startTime ≥ (len(dialogue)+len(narration))÷4,即至少满足朗读所需时间。\n - 如果当前endTime不够,必须向后延长,并相应调整后续镜头的startTime。\n\n5. 口型适配规则 —— 有台词的镜头(dialogue 不为空)必须遵守:\n - shotSize 必须为中景或近景(不能是远景、全景或特写)。\n - 说话角色必须面向或侧向镜头,不能背对镜头说话。\n") + fmt.Fprintf(&b, "【创作规范】\n\n【总时长约束】本集固定时长 %d 秒,所有镜头时长之和必须等于此值(含朗读时间)。\n\n【重要】剧情描述已包含结构化镜头信息,执行「只补缺不创作」模式:\n\n1. 严格保留已有内容 —— 剧情描述中每个镜头已包含的字段(场景、天气、运镜、开始时间、结束时间、台词、画外音、环境音、景别、人物、道具)直接原样保留到输出 JSON 的对应字段,不做任何改写或润色。\n\n2. 只补充缺失字段 —— 检查每个镜头缺少哪些字段,仅对确实缺失的字段进行补充:\n - 缺少景别(shotSize)→ 按镜头内容从「远景/全景/中景/近景/特写」中选择合适的\n - 缺少运镜(cameraMovement)→ 按场景类型从「固定镜头/推/拉/摇/移/跟/升/降/旋转/晃动/航拍」中选择\n - 缺少台词/画外音/环境音 → 若剧情描述中已写明则原样保留,未写就留空\n - 缺少时间 → 按总时长和镜头顺序合理分配\n - scene/characters/props 缺失 → 从「可用场景/演员/道具」中选择匹配的\n - characters 推断依据:当前镜头的 event(画面描述)中出现的角色、dialogue(台词\"角色名:\"前缀)中的发言角色、narration 中涉及的角色,三者必选其一。角色既没在画面中出现、也没台词、旁白也没涉及,就不能写入 characters\n\n3. 禁止新增镜头或情节 —— 严格按照剧情描述中已有的镜头分段输出,不得自行拆分、合并、增加剧情描述中没有的镜头或动作。剧情描述中有几个镜头就输出几个镜头。\n\n - 如果剧情内容超过 %d 秒容量,优先保证所有台词完整保留,非核心画面描述可适当精简。\n\n4. 朗读时长约束 —— 有台词(dialogue)或旁白(narration)的镜头必须遵守:\n - 镜头时长(endTime−startTime)必须足以容纳配音内容。参考中文语速"+speedPrompt+"。\n - 要求:endTime−startTime ≥ (len(dialogue)+len(narration))÷对应语速值,即至少满足朗读所需时间。\n - 如果当前endTime不够,必须向后延长,并相应调整后续镜头的startTime。\n\n5. 口型适配规则 —— 有台词的镜头(dialogue 不为空)必须遵守:\n - shotSize 必须为中景或近景(不能是远景、全景或特写)。\n - 说话角色必须面向或侧向镜头,不能背对镜头说话。\n", d.EpisodeDuration, d.EpisodeDuration) } else { - b.WriteString("【创作规范】\n\n1. 剧情完整性【核心规则】—— 剧情描述是唯一创作依据,严格按每一句话、每一个细节逐拍还原。\n - 剧情描述中出现的每一句角色说的话,必须一字不差地写入对应镜头的dialogue字段。严禁把台词\"翻译\"成事件描述。\n - 剧情描述中的每一个具体细节(人物的动作、表情反应、对话、环境互动等)都必须忠实地保留在对应镜头的event、dialogue或narration字段中,不能省略、概括或改写为泛化描述。\n - 所有镜头时长之和应等于 %d 秒(50秒至少20个镜头,20-25个镜头为最佳密度)。\n\n2. 场景一致性 —— 每个镜头的 scene 字段值必须与 event 中画面描述的地点一致。\n - 如果 event 描述角色在\"实验室内\",scene 必须是该实验室的名称,不能写成其他地点。\n - 如果角色从一个场景移动到另一个场景,必须先切换 scene(如从「街道」切到「室内」),再描述新地点中的动作。\n\n3. 景别与动作匹配规则 —— 按镜头内容选择正确的 shotSize:\n - shotSize=特写:面部表情、微表情、眼神变化、细节反应(手部小动作、物品细节)。不能用于动作展示。\n - shotSize=近景:对话、上半身互动、情绪交流。能看清面部表情和手势。\n - shotSize=中景:日常动作、手持物体、多人交互。膝盖以上,既有动作又有空间感。\n - shotSize=全景:连贯动作、奔跑、打斗、空间关系。展示全身动作和环境关系。\n - shotSize=远景:环境交代、空镜过渡、场景建立。没有人或人很小,用于转场。\n\n4. 朗读时长约束(硬性规则)—— 有台词(dialogue)或旁白(narration)的镜头必须遵守:\n - 镜头时长(endTime−startTime)必须足以容纳配音内容。参考中文语速约4-5字/秒。\n - 要求:endTime−startTime ≥ (len(dialogue)+len(narration))÷4,即至少满足朗读所需时间。\n - 如果当前endTime不够,必须向后延长,并相应调整后续镜头的startTime。\n\n5. 运镜搭配规则 —— 按场景类型选择 cameraMovement:\n - 对话/情绪交流 → 固定镜头或缓推\n - 动作/打斗 → 跟、摇、移\n - 人物进入/离开画面 → 跟、移\n - 情绪揭示 → 推\n - 环境展示 → 摇、升降、航拍\n - 紧张/混乱 → 晃动\n - 固定镜头是默认选项,无特殊需要不使用复杂运镜。\n\n6. 口型适配规则 —— 有台词的镜头(dialogue 不为空)必须遵守:\n - shotSize 必须为中景或近景(不能是远景、全景或特写)。\n - 说话角色必须面向或侧向镜头,不能背对镜头说话。\n - 不说话只做反应的角色可用其他景别。\n") + b.WriteString(fmt.Sprintf("【创作规范】\n\n1. 剧情完整性【核心规则】—— 剧情描述是唯一创作依据,严格按每一句话、每一个细节逐拍还原。\n - 剧情描述中出现的每一句角色说的话,必须一字不差地写入对应镜头的dialogue字段。严禁把台词\"翻译\"成事件描述。\n - 剧情描述中的每一个具体细节(人物的动作、表情反应、对话、环境互动等)都必须忠实地保留在对应镜头的event、dialogue或narration字段中,不能省略、概括或改写为泛化描述。\n - 所有镜头时长之和应等于 %d 秒(50秒至少20个镜头,20-25个镜头为最佳密度)。\n\n2. 场景一致性 —— 每个镜头的 scene 字段值必须与 event 中画面描述的地点一致。\n - 如果 event 描述角色在\"实验室内\",scene 必须是该实验室的名称,不能写成其他地点。\n - 如果角色从一个场景移动到另一个场景,必须先切换 scene(如从「街道」切到「室内」),再描述新地点中的动作。\n\n3. 人物引用规则(硬性规则)—— characters 字段必须从本镜头的画面描述、台词、旁白中推断:\n - event 画面描述中提到了谁 → 谁就必须在 characters 中\n - dialogue 中的发言角色(\"角色名:台词\"格式中的角色名)→ 该角色必须在 characters 中\n - narration 中涉及的角色 → 该角色必须在 characters 中\n - 以上三条取并集。不在画面中、不说话、旁白也不涉及的角色即使在整部剧里很重要,本镜头也不能写入 characters。\n\n4. 景别与动作匹配规则 —— 按镜头内容选择正确的 shotSize:\n - shotSize=特写:面部表情、微表情、眼神变化、细节反应(手部小动作、物品细节)。不能用于动作展示。\n - shotSize=近景:对话、上半身互动、情绪交流。能看清面部表情和手势。\n - shotSize=中景:日常动作、手持物体、多人交互。膝盖以上,既有动作又有空间感。\n - shotSize=全景:连贯动作、奔跑、打斗、空间关系。展示全身动作和环境关系。\n - shotSize=远景:环境交代、空镜过渡、场景建立。没有人或人很小,用于转场。\n\n5. 朗读时长约束(硬性规则)—— 有台词(dialogue)或旁白(narration)的镜头必须遵守:\n - 镜头时长(endTime−startTime)必须足以容纳配音内容。参考中文语速"+speedPrompt+"。\n - 要求:endTime−startTime ≥ (len(dialogue)+len(narration))÷对应语速值,即至少满足朗读所需时间。\n - 如果当前endTime不够,必须向后延长,并相应调整后续镜头的startTime。\n\n6. 运镜搭配规则 —— 按场景类型选择 cameraMovement:\n - 对话/情绪交流 → 固定镜头或缓推\n - 动作/打斗 → 跟、摇、移\n - 人物进入/离开画面 → 跟、移\n - 情绪揭示 → 推\n - 环境展示 → 摇、升降、航拍\n - 紧张/混乱 → 晃动\n - 固定镜头是默认选项,无特殊需要不使用复杂运镜。\n\n7. 口型适配规则 —— 有台词的镜头(dialogue 不为空)必须遵守:\n - shotSize 必须为中景或近景(不能是远景、全景或特写)。\n - 说话角色必须面向或侧向镜头,不能背对镜头说话。\n - 不说话只做反应的角色可用其他景别。\n", d.EpisodeDuration)) } b.WriteString(agent.GetShotDurationPrompt(ctx, d.Type)) b.WriteString(agent.GetStylePrompt(ctx, d.Type)) - b.WriteString("\n\n7. JSON格式要求:输出必须是合法的 JSON 数组,字符串值中若需使用引号请用「」代替 ASCII 双引号,确保 json.Unmarshal 能正确解析。\n8. 风控规避:视频模型对某些画面有安全审查机制,生成镜头描述时必须规避。政治敏感内容(真实政治人物/标志/当代军警标识)零容忍。暴力血腥画面(刀刺入身体、血溅、断肢)改为反应镜头或切镜回避,不展示伤害过程。当代背景中的军警抓捕动作将「警察/武警」改为「保安/黑衣人」,动作从「按倒铐住」改为「围住请离」。武器描写聚焦持武器的人物(「他握紧长刀,目光凌厉」),不写武器接触身体的画面。暴力结果用间接描写:写周围人反应而非伤口血迹。\n") + b.WriteString("\n\n8. JSON格式要求:输出必须是合法的 JSON 数组,字符串值中若需使用引号请用「」代替 ASCII 双引号,确保 json.Unmarshal 能正确解析。\n8. 风控规避:视频模型对某些画面有安全审查机制,生成镜头描述时必须规避。政治敏感内容(真实政治人物/标志/当代军警标识)零容忍。暴力血腥画面(刀刺入身体、血溅、断肢)改为反应镜头或切镜回避,不展示伤害过程。当代背景中的军警抓捕动作将「警察/武警」改为「保安/黑衣人」,动作从「按倒铐住」改为「围住请离」。武器描写聚焦持武器的人物(「他握紧长刀,目光凌厉」),不写武器接触身体的画面。暴力结果用间接描写:写周围人反应而非伤口血迹。\n") return b.String() } @@ -422,6 +605,10 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, } } + charPerSecond := g.Cfg().MustGet(ctx, "speech.charPerSecond", 4).Int() + fastCharPerSecond := g.Cfg().MustGet(ctx, "speech.fastCharPerSecond", 6).Int() + slowCharPerSecond := g.Cfg().MustGet(ctx, "speech.slowCharPerSecond", 3).Int() + // 读取反向提示词 negativePrompt := "" if data, err := os.ReadFile(getDataPath("negative_prompt.md")); err == nil { @@ -436,15 +623,15 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, // 从 video_schema 读取 prompt 最大字符数(用于拆段时控制每段 prompt 长度) promptMaxChars := intVal(nested(vs, "body", "input", "prompt", "max_chars"), 0) - // 读取拆段时长约束(与 calcSegDurs 逻辑一致) + // 读取拆段时长约束(通过 schema_mapping 从 schema 中读取,与 calcSegDurs 逻辑一致) effectiveMax := 15 minSingle := 5 - if vs != nil { - if v := intVal(nested(vs, "body", "parameters", "duration", "max"), 0); v > 0 { - effectiveMax = v - } - if v := intVal(nested(vs, "body", "parameters", "duration", "min"), 0); v > 0 { - minSingle = v + if modelCfg.Schema != "" && modelCfg.SchemaMapping != "" { + if minDur, maxDur := schemaDurationBounds(modelCfg.Schema, modelCfg.SchemaMapping); maxDur > 0 { + effectiveMax = maxDur + if minDur > 0 { + minSingle = minDur + } } } if minSingle > effectiveMax { @@ -521,7 +708,7 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, return fmt.Errorf("parse shot script failed: %w", err) } // 朗读时长后处理:检查每个镜头的台词/旁白能否在分配时长内读完,不足则自动延长 - fixupShotDurationsForNarration(&allShots) + fixupShotDurationsForNarration(&allShots, int(d.EpisodeDuration), charPerSecond, fastCharPerSecond, slowCharPerSecond) // 将校正后的镜头时间持久化回 ep.Script(确保 generateOneSegment 使用校正后的时间) if fixedJSON, err := json.Marshal(allShots); err == nil { fixedStr := string(fixedJSON) @@ -531,6 +718,11 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, } } } + // 拆分超长镜头以遵守模型单次请求最大时长限制(如15s), + // 台词/旁白在句末断句分配到子镜头中,避免"说话说一半" + if effectiveMax > 0 { + allShots = splitOversizedShots(allShots, effectiveMax) + } type _shotGroup struct { shots []domain.Shot totalDur int @@ -607,7 +799,19 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, // ============ 插入任务 ============ bodyDef, _ := nested(vs, "body").(map[string]any) - _, mediaTypeField, mediaURLField, _ := resolveMediaFields(modelCfg.Schema, modelCfg.FirstFrameMapping) + // 从 schema_mapping 解析 media 字段定义和 reference_image 的 typeValue + _, mediaTypeField, mediaURLField, _ := resolveMediaFields(modelCfg.Schema, modelCfg.SchemaMapping) + refTypeValue := "reference_image" + if modelCfg.SchemaMapping != "" && strings.HasPrefix(modelCfg.SchemaMapping, "{") { + var sm struct { + RefImg string `json:"reference_image"` + } + if json.Unmarshal([]byte(modelCfg.SchemaMapping), &sm) == nil && sm.RefImg != "" { + if def := parseMediaDef(sm.RefImg); def != nil { + refTypeValue = def.TypeValue + } + } + } var records g.List for i, tg := range taskGroups { segInput := map[string]any{ @@ -618,7 +822,7 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, if negativePrompt != "" { segInput["negative_prompt"] = negativePrompt } - segInput["media"] = buildSchemaMedia(refURLs, mediaTypeField, mediaURLField) + segInput["media"] = buildSchemaMedia(refURLs, mediaTypeField, mediaURLField, refTypeValue) refsAny := make([]any, len(refURLs)) for j, u := range refURLs { refsAny[j] = u @@ -642,7 +846,7 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, } } if i > 0 { - injectFirstFrame(body, "", modelCfg.FirstFrameMapping, modelCfg.Schema) + injectFirstFrame(body, "", modelCfg.SchemaMapping, modelCfg.Schema) } bodyJSON, _ := json.Marshal(body) @@ -718,40 +922,395 @@ func splitScriptForSegment(fullScript string, segStartTime, segDur, totalDur int return strings.Join(paragraphs[startPara:endPara], "\n\n") } -// fixupShotDurationsForNarration 检查每个镜头中台词/旁白的朗读时长是否超过镜头分配时长, -// 如果不够则自动延长该镜头的 endTime,并相应后移后续所有镜头的时间。 -// 中文语速按4字/秒估算。旁白和台词均需配音,合并计算。 -func fixupShotDurationsForNarration(shots *[]domain.Shot) { +// inferCharPerSecond 根据台词的语气特征推断语速 +// 台词含大量感叹/疑问(!?)或短促句式 → 激动语速(fast) +// 台词含省略号(……)、唉等拖沓语气 → 低沉语速(slow) +// 其他情况 → 正常语速(normal) +func inferCharPerSecond(text string, normal, fast, slow int) int { + runes := []rune(text) + if len(runes) == 0 { + return normal + } + excl := strings.Count(text, "!") + strings.Count(text, "!") + ques := strings.Count(text, "?") + strings.Count(text, "?") + emotionPunct := excl + ques + + // 感叹/疑问占比高 → 激动语速 + if emotionPunct >= 2 && emotionPunct*100/len(runes) >= 8 { + return fast + } + + // 含省略号或"唉"等低落语气词 → 低沉语速 + if strings.Contains(text, "…") || strings.Contains(text, "唉") || strings.Contains(text, "……") { + return slow + } + + return normal +} + +// fixupShotDurationsForNarration 检查每个镜头中台词/旁白的朗读时长是否超过分配时长。 +// 延长不足的镜头,并从所有有富余时长的镜头(含无声镜头)中扣减时长来补偿。 +// 语速由 config.yml speech 配置(charPerSecond/fastCharPerSecond/slowCharPerSecond),根据台词情绪自动选择。 +// 如果总富余时长不够,缺时镜头按比例缩减延长时间;Phase 6 仅压缩无声镜头对齐总时长。 +func fixupShotDurationsForNarration(shots *[]domain.Shot, episodeDuration int, charPerSecond, fastCharPerSecond, slowCharPerSecond int) { if shots == nil || len(*shots) == 0 { return } - const charPerSecond = 4 - totalShift := 0 + + // Phase 1:计算每个镜头的原始时长和朗读所需时长 + type shotAdj struct { + origDur int + speakingDur int + deficit int // 需要延长多少秒 + isSilent bool + } + adjs := make([]shotAdj, len(*shots)) + totalDeficit := 0 + for i, sh := range *shots { + dur := parseMMSSToSeconds(sh.EndTime) - parseMMSSToSeconds(sh.StartTime) + if dur <= 0 { + dur = 1 + } + textLen := len([]rune(sh.Dialogue)) + len([]rune(sh.Narration)) + cps := inferCharPerSecond(sh.Dialogue+" "+sh.Narration, charPerSecond, fastCharPerSecond, slowCharPerSecond) + speakingDur := textLen / cps + if textLen%cps != 0 { + speakingDur++ + } + deficit := 0 + minNeed := speakingDur + 1 + if minNeed > dur { + deficit = minNeed - dur + } + adjs[i] = shotAdj{ + origDur: dur, + speakingDur: speakingDur, + deficit: deficit, + isSilent: textLen == 0, + } + totalDeficit += deficit + } + if totalDeficit == 0 { + return + } + + // Phase 2:收集所有可捐赠时长的镜头(有声和无声均可) + // 无声镜头:保留至少 2s + // 有声镜头:保留至少 speakingDur(朗读所需时长,不含1秒余量) + // 超出保留下限的部分即为可捐赠时长 + // 这样可将更多富余时长让给缺时镜头,Phase 6的压缩底线会再保护+1余量 + type si struct{ idx, avail int } + var donors []si + for i, a := range adjs { + minKeep := 2 + if !a.isSilent { + minKeep = a.speakingDur + } + if a.origDur > minKeep { + donors = append(donors, si{idx: i, avail: a.origDur - minKeep}) + } + } + + // Phase 3:收集总可用时长——所有捐赠镜头扣减到底线 + // 无声镜头底线 = 2s,有声镜头底线 = speakingDur(可牺牲 +1 缓冲来支援缺时镜头) + totalFreed := 0 + donorNewDur := make(map[int]int) + for _, d := range donors { + floor := 2 + if !adjs[d.idx].isSilent { + floor = adjs[d.idx].speakingDur + } + donorNewDur[d.idx] = floor + totalFreed += adjs[d.idx].origDur - floor + } + + // Phase 4:按缺时严重程度排序(缺时最多的优先分配) + type deficitItem struct{ idx, deficit int } + var deficitItems []deficitItem + for i, a := range adjs { + if a.deficit > 0 { + deficitItems = append(deficitItems, deficitItem{idx: i, deficit: a.deficit}) + } + } + sort.Slice(deficitItems, func(i, j int) bool { + return deficitItems[i].deficit > deficitItems[j].deficit + }) + + // Phase 5:优先分配可用时长给最缺时的镜头,再重建时间线 + remaining := totalFreed + deficitNewDur := make(map[int]int, len(deficitItems)) + for _, di := range deficitItems { + alloc := di.deficit + if alloc > remaining { + alloc = remaining + } + deficitNewDur[di.idx] = adjs[di.idx].origDur + alloc + remaining -= alloc + } + currentTime := 0 for i := range *shots { sh := &(*shots)[i] + newDur := adjs[i].origDur + if d, ok := deficitNewDur[i]; ok { + newDur = d + } else if d, ok := donorNewDur[i]; ok { + newDur = d + } + sh.StartTime = secondsToMMSS(currentTime) + currentTime += newDur + sh.EndTime = secondsToMMSS(currentTime) + } - // 重新计算 startTime(考虑之前镜头延长的累积偏移) - if totalShift > 0 { - oldStart := parseMMSSToSeconds(sh.StartTime) - sh.StartTime = secondsToMMSS(oldStart + totalShift) + // Phase 6:总时长对齐——压缩所有有富余时长的镜头(含无声和有声) + // 每个镜头的底线:有声镜头 = speakingDur + 1(确保台词读完并多1秒余量),无声镜头 = 2s + if episodeDuration > 0 { + totalDur := 0 + for _, sh := range *shots { + totalDur += parseMMSSToSeconds(sh.EndTime) - parseMMSSToSeconds(sh.StartTime) + } + if totalDur == episodeDuration { + return } - oldEnd := parseMMSSToSeconds(sh.EndTime) - newEnd := oldEnd + totalShift - - // 计算台词+旁白的预估朗读时长 - textLen := len([]rune(sh.Dialogue)) + len([]rune(sh.Narration)) - requiredDur := textLen / charPerSecond - if textLen%charPerSecond != 0 { - requiredDur++ - } - actualDur := newEnd - parseMMSSToSeconds(sh.StartTime) - if requiredDur > actualDur { - deficit := requiredDur - actualDur - newEnd += deficit - totalShift += deficit + // 情况A:总时长 < 目标时长 → 延长最后一个镜头补齐 + if totalDur < episodeDuration { + pad := episodeDuration - totalDur + last := &(*shots)[len(*shots)-1] + last.EndTime = secondsToMMSS(parseMMSSToSeconds(last.EndTime) + pad) + return } - sh.EndTime = secondsToMMSS(newEnd) + // 情况B:总时长 > 目标时长 → 收集所有有富余时长的镜头(含有声)进行压缩 + // 每个镜头的压缩底线 = max(speakingDur + 1, 2),确保台词读完 + over_shoot := totalDur - episodeDuration + + type _ci struct { + idx int + cur int + floor int + } + var compressibles []_ci + for i, sh := range *shots { + d := parseMMSSToSeconds(sh.EndTime) - parseMMSSToSeconds(sh.StartTime) + floor := 2 + if !adjs[i].isSilent { + floor = adjs[i].speakingDur + 1 + } + if d > floor { + compressibles = append(compressibles, _ci{idx: i, cur: d, floor: floor}) + } + } + + if len(compressibles) > 0 { + available := 0 + for _, c := range compressibles { + available += c.cur - c.floor + } + if available >= over_shoot { + // 均匀缩减即可对齐总时长 + baseReduce := over_shoot / len(compressibles) + remainder := over_shoot % len(compressibles) + reduceMap := make(map[int]int, len(compressibles)) + for _, c := range compressibles { + r := baseReduce + if remainder > 0 { + r++ + remainder-- + } + reduceMap[c.idx] = r + } + currentTime := 0 + for i := range *shots { + sh := &(*shots)[i] + d := parseMMSSToSeconds(sh.EndTime) - parseMMSSToSeconds(sh.StartTime) + if r, ok := reduceMap[i]; ok { + d -= r + // 不低于底线 + floor := 2 + if !adjs[i].isSilent { + floor = adjs[i].speakingDur + 1 + } + if d < floor { + d = floor + } + } + sh.StartTime = secondsToMMSS(currentTime) + currentTime += d + sh.EndTime = secondsToMMSS(currentTime) + } + return + } + // 不够对齐时压缩所有镜头到其底线 + for _, c := range compressibles { + newEnd := parseMMSSToSeconds((*shots)[c.idx].StartTime) + c.floor + (*shots)[c.idx].EndTime = secondsToMMSS(newEnd) + } + currentTime := 0 + for i := range *shots { + sh := &(*shots)[i] + d := parseMMSSToSeconds(sh.EndTime) - parseMMSSToSeconds(sh.StartTime) + sh.StartTime = secondsToMMSS(currentTime) + currentTime += d + sh.EndTime = secondsToMMSS(currentTime) + } + // 压到底线后重新计算,仍超限则接受 + totalDur = 0 + for _, sh := range *shots { + totalDur += parseMMSSToSeconds(sh.EndTime) - parseMMSSToSeconds(sh.StartTime) + } + if totalDur <= episodeDuration { + return + } + } + // 压缩到底线仍超限 -> 从尾部逐个截断镜头 + for len(*shots) > 1 { + totalDur = 0 + for _, sh := range *shots { + totalDur += parseMMSSToSeconds(sh.EndTime) - parseMMSSToSeconds(sh.StartTime) + } + if totalDur <= episodeDuration { + break + } + *shots = (*shots)[:len(*shots)-1] + } + // 截断后重建时间线 + if len(*shots) > 0 { + currentTime := 0 + for i := range *shots { + sh := &(*shots)[i] + d := parseMMSSToSeconds(sh.EndTime) - parseMMSSToSeconds(sh.StartTime) + sh.StartTime = secondsToMMSS(currentTime) + currentTime += d + sh.EndTime = secondsToMMSS(currentTime) + } + } } } +func splitOversizedShots(shots []domain.Shot, maxDur int) []domain.Shot { + if maxDur <= 0 || len(shots) == 0 { + return shots + } + // 中文及英文句末标点 + isEnd := func(r rune) bool { + return r == '。' || r == '!' || r == '?' || r == ';' || r == '\n' || r == '!' || r == '?' || r == '.' + } + // 在 rs 中从 pos 开始查找最近的句末位置,返回包含标点的索引 + // forwardFirst=true 优先向后找,否则优先向前找 + findBoundary := func(rs []rune, pos int, forwardFirst bool) int { + if len(rs) == 0 || pos <= 0 { + return 0 + } + if pos >= len(rs) { + return len(rs) + } + limit := 60 + if forwardFirst { + end := pos + limit + if end > len(rs) { + end = len(rs) + } + for i := pos; i < end; i++ { + if isEnd(rs[i]) { + return i + 1 + } + } + start := pos - limit + if start < 0 { + start = 0 + } + for i := pos - 1; i >= start; i-- { + if isEnd(rs[i]) { + return i + 1 + } + } + } else { + start := pos - limit + if start < 0 { + start = 0 + } + for i := pos - 1; i >= start; i-- { + if isEnd(rs[i]) { + return i + 1 + } + } + end := pos + limit + if end > len(rs) { + end = len(rs) + } + for i := pos; i < end; i++ { + if isEnd(rs[i]) { + return i + 1 + } + } + } + return pos + } + + var result []domain.Shot + for _, sh := range shots { + dur := parseMMSSToSeconds(sh.EndTime) - parseMMSSToSeconds(sh.StartTime) + if dur <= maxDur { + result = append(result, sh) + continue + } + parts := (dur + maxDur - 1) / maxDur + dRunes := []rune(sh.Dialogue) + nRunes := []rune(sh.Narration) + baseStart := parseMMSSToSeconds(sh.StartTime) + + dPos, nPos := 0, 0 + for p := 0; p < parts; p++ { + sub := sh + pStart := baseStart + p*maxDur + pEnd := pStart + maxDur + if p > 0 { + sub.StartTime = secondsToMMSS(pStart) + } + if pEnd > baseStart+dur { + pEnd = baseStart + dur + } + sub.EndTime = secondsToMMSS(pEnd) + + if p < parts-1 { + // 非最后一段:按比例定位并在句末断句 + dTarget := len(dRunes) * (p + 1) / parts + if dTarget > dPos && dTarget <= len(dRunes) { + dEnd := findBoundary(dRunes, dTarget, true) + if dEnd <= dPos { + dEnd = dPos + 1 + } + if dEnd > len(dRunes) { + dEnd = len(dRunes) + } + sub.Dialogue = string(dRunes[dPos:dEnd]) + dPos = dEnd + } + + nTarget := len(nRunes) * (p + 1) / parts + if nTarget > nPos && nTarget <= len(nRunes) { + nEnd := findBoundary(nRunes, nTarget, true) + if nEnd <= nPos { + nEnd = nPos + 1 + } + if nEnd > len(nRunes) { + nEnd = len(nRunes) + } + sub.Narration = string(nRunes[nPos:nEnd]) + nPos = nEnd + } + } else { + // 最后一段:剩余全部 + sub.Dialogue = string(dRunes[dPos:]) + sub.Narration = string(nRunes[nPos:]) + } + + result = append(result, sub) + } + } + + // 重排索引 + for i := range result { + result[i].Index = i + 1 + } + return result +} diff --git a/shortdrama/service/generation_service.go b/shortdrama/service/generation_service.go index 3eff497..3983166 100644 --- a/shortdrama/service/generation_service.go +++ b/shortdrama/service/generation_service.go @@ -733,7 +733,7 @@ func (s *generationService) FeedbackSegment(ctx context.Context, taskId int64, f bodyMap["model"] = modelCfg.ModelName } - prepareFirstFrame(genCtx, bodyMap, task.SegmentIdx, taskId, task.EpisodeId, d.Title, ep.Title, modelCfg.FirstFrameMapping, modelCfg.Schema) + prepareFirstFrame(genCtx, bodyMap, task.SegmentIdx, taskId, task.EpisodeId, d.Title, ep.Title, modelCfg.SchemaMapping, modelCfg.Schema) bodyBytes, err := json.Marshal(bodyMap) if err != nil { @@ -744,7 +744,7 @@ func (s *generationService) FeedbackSegment(ctx context.Context, taskId int64, f // 调用模型前写入模型名称 _ = dao.GenerationTask.UpdateFields(genCtx, taskId, g.Map{"model_name": modelCfg.ModelName}) - newTaskID, resolvedBody, submitErr := resubmitVideoTask(genCtx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.Schema, bodyBytes, modelCfg.FirstFrameMapping) + newTaskID, resolvedBody, submitErr := resubmitVideoTask(genCtx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.Schema, bodyBytes, modelCfg.SchemaMapping) if submitErr != nil { g.Log().Errorf(genCtx, "episode %d segment %d video resubmit failed: %v", ep.Index, task.SegmentIdx+1, submitErr) _ = dao.GenerationTask.UpdateFailed(genCtx, taskId, submitErr.Error()) @@ -931,17 +931,14 @@ func cleanupEpisodeWorkspace(ctx context.Context, dramaTitle string, epIndex int } func calcSegDurs(episodeDuration int64, cfg *entity.ModelConfig) []int { - // 从 video_schema.duration 读取模型单段时长约束 + // 从 schema_mapping 读取模型单段时长约束 effectiveMax := 15 // 默认值 minSingle := 5 - if cfg.Schema != "" { - var vs map[string]any - if err := json.Unmarshal([]byte(cfg.Schema), &vs); err == nil { - if v := intVal(nested(vs, "body", "parameters", "duration", "max"), 0); v > 0 { - effectiveMax = v - } - if v := intVal(nested(vs, "body", "parameters", "duration", "min"), 0); v > 0 { - minSingle = v + if cfg.Schema != "" && cfg.SchemaMapping != "" { + if minDur, maxDur := schemaDurationBounds(cfg.Schema, cfg.SchemaMapping); maxDur > 0 { + effectiveMax = maxDur + if minDur > 0 { + minSingle = minDur } } } @@ -1800,7 +1797,7 @@ func (s *generationService) processOneEpisode(ctx context.Context, tasks []*enti } // 首尾帧拼接模式:前一段未完成时不提交下一段 - if modelCfg.FirstFrameMapping != "" && current.SegmentIdx > 0 { + if modelCfg.SchemaMapping != "" && current.SegmentIdx > 0 { prev := findTaskBySegmentIdx(ctx, current.EpisodeId, current.SegmentIdx-1) if prev == nil || prev.VideoUrl == "" { return @@ -1816,7 +1813,7 @@ func (s *generationService) processOneEpisode(ctx context.Context, tasks []*enti } } // 统一处理首帧(segIdx==0 删除,>0 自动解析或截取尾帧) - prepareFirstFrame(ctx, bodyMap, current.SegmentIdx, current.Id, current.EpisodeId, drama.Title, epTitle, modelCfg.FirstFrameMapping, modelCfg.Schema) + prepareFirstFrame(ctx, bodyMap, current.SegmentIdx, current.Id, current.EpisodeId, drama.Title, epTitle, modelCfg.SchemaMapping, modelCfg.Schema) bodyJSON, err := json.Marshal(bodyMap) if err != nil { @@ -1827,7 +1824,7 @@ func (s *generationService) processOneEpisode(ctx context.Context, tasks []*enti // 调用模型前写入模型名称 _ = dao.GenerationTask.UpdateFields(ctx, current.Id, g.Map{"model_name": modelCfg.ModelName}) - taskId, _, err := resubmitVideoTask(ctx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.Schema, bodyJSON, modelCfg.FirstFrameMapping) + taskId, _, err := resubmitVideoTask(ctx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.Schema, bodyJSON, modelCfg.SchemaMapping) if err != nil { g.Log().Errorf(ctx, "poller: segment %d video submit failed: %v", current.SegmentIdx+1, err) return @@ -1991,7 +1988,7 @@ func (s *generationService) submitFromExistingScript(ctx context.Context, taskId // 设置模型名并统一处理首帧 bodyMap["model"] = modelCfg.ModelName - prepareFirstFrame(ctx, bodyMap, segIdx, taskId, task.EpisodeId, dramaTitle, epTitle, modelCfg.FirstFrameMapping, modelCfg.Schema) + prepareFirstFrame(ctx, bodyMap, segIdx, taskId, task.EpisodeId, dramaTitle, epTitle, modelCfg.SchemaMapping, modelCfg.Schema) // 将修改后的 body(含文件路径)序列化,用于保存到 task.script updatedBody, err := json.Marshal(bodyMap) @@ -2000,7 +1997,7 @@ func (s *generationService) submitFromExistingScript(ctx context.Context, taskId } // 复用 resubmitVideoTask 将文件路径转 base64 并提交 API - taskID, _, err := resubmitVideoTask(ctx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.Schema, updatedBody, modelCfg.FirstFrameMapping) + taskID, _, err := resubmitVideoTask(ctx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.Schema, updatedBody, modelCfg.SchemaMapping) if err != nil { return "", "", err } @@ -2146,7 +2143,7 @@ func (s *generationService) submitVideoTask(ctx context.Context, d *entity.Drama // 固定 seed 确保各段画风/人物形象一致(基于短剧ID+剧集序号) seed := int(d.Id)*1000 + ep.Index*10 taskId, requestJSON, err := submitVideoGenRequest(ctx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.ModelName, - prompt, negativePrompt, refURLs, effectiveDur, d.Resolution, d.AspectRatio, modelCfg.Schema, seed, segIdx, genTaskId, ep.Id, d.Title, ep.Title, modelCfg.FirstFrameMapping) + prompt, negativePrompt, refURLs, effectiveDur, d.Resolution, d.AspectRatio, modelCfg.Schema, seed, segIdx, genTaskId, ep.Id, d.Title, ep.Title, modelCfg.SchemaMapping) if err != nil { return "", "", fmt.Errorf("video composition request failed: %w", err) } @@ -2205,13 +2202,25 @@ func callVideoAPI(ctx context.Context, apiKey, baseURL string, payload []byte, s // // params: 额外参数(如 audio/shot_type/watermark 等) // sizes: 分辨率→尺寸字符串映射表 -func submitVideoGenRequest(ctx context.Context, apiKey, baseURL, modelName, prompt, negativePrompt string, refURLs []string, duration int, resolution, aspectRatio, videoSchema string, seed int, segIdx int, genTaskId int64, episodeId int64, dramaTitle, epTitle, firstFrameMapping string) (string, string, error) { +func submitVideoGenRequest(ctx context.Context, apiKey, baseURL, modelName, prompt, negativePrompt string, refURLs []string, duration int, resolution, aspectRatio, videoSchema string, seed int, segIdx int, genTaskId int64, episodeId int64, dramaTitle, epTitle, schemaMapping string) (string, string, error) { var vs map[string]any if videoSchema != "" { json.Unmarshal([]byte(videoSchema), &vs) } - _, mediaTypeField, mediaURLField, _ := resolveMediaFields(videoSchema, firstFrameMapping) + // 从 schema_mapping 解析 media 字段定义和 reference_image 的 typeValue + _, mediaTypeField, mediaURLField, _ := resolveMediaFields(videoSchema, schemaMapping) + refTypeValue := "reference_image" + if strings.HasPrefix(schemaMapping, "{") { + var sm struct { + RefImg string `json:"reference_image"` + } + if json.Unmarshal([]byte(schemaMapping), &sm) == nil && sm.RefImg != "" { + if def := parseMediaDef(sm.RefImg); def != nil { + refTypeValue = def.TypeValue + } + } + } // 通过 BuildSchemaRequest 构建请求体:flat input → schema 校验/填充 → 嵌套结构 input := map[string]any{ @@ -2225,7 +2234,7 @@ func submitVideoGenRequest(ctx context.Context, apiKey, baseURL, modelName, prom input["duration"] = duration } if len(refURLs) > 0 { - input["media"] = buildSchemaMedia(refURLs, mediaTypeField, mediaURLField) + input["media"] = buildSchemaMedia(refURLs, mediaTypeField, mediaURLField, refTypeValue) refsAny := make([]any, len(refURLs)) for i, u := range refURLs { refsAny[i] = u @@ -2244,7 +2253,7 @@ func submitVideoGenRequest(ctx context.Context, apiKey, baseURL, modelName, prom body["model"] = modelName // 统一处理首帧(segIdx==0 删除,>0 解析URL) - prepareFirstFrame(ctx, body, segIdx, genTaskId, episodeId, dramaTitle, epTitle, firstFrameMapping, videoSchema) + prepareFirstFrame(ctx, body, segIdx, genTaskId, episodeId, dramaTitle, epTitle, schemaMapping, videoSchema) // 尺寸映射:从 drama 的 Resolution/AspectRatio 查 video_schema if sizes := nested(vs, "body", "parameters", "size", "sizes"); sizes != nil { if sm, ok := sizes.(map[string]any); ok { @@ -2279,7 +2288,7 @@ func submitVideoGenRequest(ctx context.Context, apiKey, baseURL, modelName, prom } // resubmitVideoTask 使用已有的 bodyJSON 重新提交视频任务(跳过 Agent,直接调 API) -func resubmitVideoTask(ctx context.Context, apiKey, baseURL, schema string, bodyJSON []byte, firstFrameMapping string) (string, []byte, error) { +func resubmitVideoTask(ctx context.Context, apiKey, baseURL, schema string, bodyJSON []byte, schemaMapping string) (string, []byte, error) { var bodyMap map[string]any if err := json.Unmarshal(bodyJSON, &bodyMap); err != nil { return "", nil, fmt.Errorf("parse bodyJSON failed: %w", err) @@ -2289,7 +2298,7 @@ func resubmitVideoTask(ctx context.Context, apiKey, baseURL, schema string, body origPayload := bodyJSON // 将 body 中的本地文件路径转为 base64,用于 API 请求 - _, _, urlField, _ := resolveMediaFields(schema, firstFrameMapping) + _, _, urlField, _ := resolveMediaFields(schema, schemaMapping) convertBodyMediaToBase64(bodyMap, urlField) apiPayload, err := json.Marshal(bodyMap) if err != nil { @@ -2373,6 +2382,97 @@ func schemaHeaders(schemaStr string) map[string]string { return headers } +// SchemaMapping schema_mapping JSON 结构 +// first_frame/reference_image/reference_video 值为 "path?typeField=typeValue&urlField=#" 格式 +// 如 "input.media?type=first_frame&url=#" +// min_duration/max_duration 值为 body 中的点号路径(不带 body 前缀) +type SchemaMapping struct { + FirstFrame string `json:"first_frame"` + ReferenceImage string `json:"reference_image"` + ReferenceVideo string `json:"reference_video"` + MinDuration string `json:"min_duration"` + MaxDuration string `json:"max_duration"` +} + +// parseSchemaMapping 解析 schema_mapping JSON 字符串 +func parseSchemaMapping(mapping string) *SchemaMapping { + if mapping == "" { + return nil + } + var sm SchemaMapping + if err := json.Unmarshal([]byte(mapping), &sm); err != nil { + return nil + } + if sm.FirstFrame == "" { + return nil + } + return &sm +} + +// mediaDef 从 "path?typeField=typeValue&urlField=#" 格式中解析出的媒体项定义 +type mediaDef struct { + Path string + TypeField string + TypeValue string + URLField string +} + +// parseMediaDef 解析 "input.media?type=first_frame&url=#" 格式字符串 +// 含 "?#" 的字段标记为值字段(URL),其余为区分字段 +func parseMediaDef(val string) *mediaDef { + if idx := strings.IndexByte(val, '?'); idx >= 0 { + def := &mediaDef{Path: val[:idx]} + for _, p := range strings.Split(val[idx+1:], "&") { + kv := strings.SplitN(p, "=", 2) + if len(kv) != 2 { + continue + } + if kv[1] == "#" { + def.URLField = kv[0] + } else { + def.TypeField = kv[0] + def.TypeValue = kv[1] + } + } + if def.Path != "" && def.TypeField != "" && def.TypeValue != "" && def.URLField != "" { + return def + } + } + return nil +} + +// schemaDurationBounds 从 schema_mapping 中提取 duration 在 body 中的点号路径,自动加 body 前缀后从 schema 获取实际约束值 +func schemaDurationBounds(schema, schemaMapping string) (minDur, maxDur int) { + sm := parseSchemaMapping(schemaMapping) + if sm == nil { + return 0, 0 + } + if sm.MinDuration == "" && sm.MaxDuration == "" { + return 0, 0 + } + var doc map[string]any + if err := json.Unmarshal([]byte(schema), &doc); err != nil { + return 0, 0 + } + if sm.MinDuration != "" { + keys := append([]string{"body"}, strings.Split(sm.MinDuration, ".")...) + if v := nested(doc, keys...); v != nil { + if f, ok := v.(float64); ok { + minDur = int(f) + } + } + } + if sm.MaxDuration != "" { + keys := append([]string{"body"}, strings.Split(sm.MaxDuration, ".")...) + if v := nested(doc, keys...); v != nil { + if f, ok := v.(float64); ok { + maxDur = int(f) + } + } + } + return +} + // convertBodyMediaToBase64 将 body 中媒体文件的本地路径转为 base64(原地修改) // 覆盖 reference_urls(字符串数组)和 media(对象数组的 urlField 字段) func convertBodyMediaToBase64(body map[string]any, urlField string) { @@ -2414,80 +2514,28 @@ func convertBodyMediaToBase64(body map[string]any, urlField string) { } } -// resolveMediaFields 从 schema 和 firstFrameMapping 中解析 media 数组的字段名 -// firstFrameMapping 格式: {path}?type={typeField}&url={urlField}[&value={typeValue}] -// 优先使用 query params 指定的字段名,缺失时从 schema(body.{path}.items.properties)中获取 -// 示例:input.media?type=type&url=url +// resolveMediaFields 从 schema_mapping 中解析 media 数组的路径和字段名 +// 默认使用 first_frame 条目解析;其他条目通过 parseMediaDef 单独查询 func resolveMediaFields(schema, mapping string) (path, typeField, urlField, typeValue string) { - if mapping == "" { + if mapping == "" || !strings.HasPrefix(mapping, "{") { return "", "", "", "" } - typeValue = "first_frame" - - var queryParams string - if idx := strings.Index(mapping, "?"); idx >= 0 { - path = mapping[:idx] - queryParams = mapping[idx+1:] - } else { - path = mapping + var sm struct { + FirstFrame string `json:"first_frame"` } - - // 1. 优先从 query params 读取明确的字段映射 - if queryParams != "" { - for _, kv := range strings.Split(queryParams, "&") { - parts := strings.SplitN(kv, "=", 2) - if len(parts) != 2 { - continue - } - switch parts[0] { - case "type": - typeField = parts[1] - case "url": - urlField = parts[1] - case "value": - typeValue = parts[1] - } - } + if err := json.Unmarshal([]byte(mapping), &sm); err != nil || sm.FirstFrame == "" { + return "", "", "", "" } - - // 2. query params 未完全指定时,从 schema 补全字段名 - if schema != "" && (typeField == "" || urlField == "") { - schemaKeys := append([]string{"body"}, strings.Split(path, ".")...) - schemaKeys = append(schemaKeys, "items", "properties") - - var doc map[string]any - if json.Unmarshal([]byte(schema), &doc) == nil { - if props, ok := nested(doc, schemaKeys...).(map[string]any); ok { - for k, v := range props { - if k == typeField || k == urlField { - continue - } - if typeField == "" { - // 有 enum 约束的是类型判别器字段 - if def, ok := v.(map[string]any); ok && def["enum"] != nil { - typeField = k - continue - } - if k == "type" { - typeField = k - continue - } - } - if urlField == "" && typeField != "" && k != typeField { - urlField = k - } - } - } - } + if def := parseMediaDef(sm.FirstFrame); def != nil { + return def.Path, def.TypeField, def.TypeValue, def.URLField } - return } -// injectFirstFrame 按 firstFrameMapping 路径将首帧图片注入请求体 media 数组 +// injectFirstFrame 按 schemaMapping.media 路径将首帧图片注入请求体 media 数组 // 字段名优先从 schema 解析;firstFrameURL 为空时仅添加占位(不更新URL) -func injectFirstFrame(body map[string]any, firstFrameURL, firstFrameMapping, schema string) { - path, typeField, urlField, typeValue := resolveMediaFields(schema, firstFrameMapping) +func injectFirstFrame(body map[string]any, firstFrameURL, schemaMapping, schema string) { + path, typeField, urlField, typeValue := resolveMediaFields(schema, schemaMapping) if path == "" || typeField == "" || urlField == "" || typeValue == "" { return } @@ -2527,8 +2575,8 @@ func injectFirstFrame(body map[string]any, firstFrameURL, firstFrameMapping, sch // prepareFirstFrame 调用视频模型前统一处理首帧逻辑 // 字段名优先从 schema 解析;segIdx==0: 删除;>0 && url=="" 时 ffmpeg 截取尾帧 -func prepareFirstFrame(ctx context.Context, body map[string]any, segIdx int, genTaskId, episodeId int64, dramaTitle, epTitle, firstFrameMapping, schema string) { - path, typeField, urlField, typeValue := resolveMediaFields(schema, firstFrameMapping) +func prepareFirstFrame(ctx context.Context, body map[string]any, segIdx int, genTaskId, episodeId int64, dramaTitle, epTitle, schemaMapping, schema string) { + path, typeField, urlField, typeValue := resolveMediaFields(schema, schemaMapping) if path == "" || typeField == "" || urlField == "" { return } @@ -2654,20 +2702,23 @@ func getSchemaField(body map[string]any, name string) any { } // buildSchemaMedia 将 URL 数组转换为 media 数组格式,字段名由调用方指定 -func buildSchemaMedia(urls []string, typeField, urlField string) []any { +func buildSchemaMedia(urls []string, typeField, urlField, typeValue string) []any { if typeField == "" { typeField = "type" } if urlField == "" { urlField = "url" } + if typeValue == "" { + typeValue = "reference_image" + } media := make([]any, 0, len(urls)) for _, u := range urls { if u == "" { continue } media = append(media, map[string]any{ - typeField: "reference_image", + typeField: typeValue, urlField: u, }) }