1
This commit is contained in:
@@ -17,6 +17,12 @@ chat:
|
||||
timeout: 3000 # 对话模型API请求超时时间(秒)
|
||||
max_retries: 3 # 请求失败最大重试次数
|
||||
|
||||
# 朗读速度配置(字/秒)
|
||||
speech:
|
||||
charPerSecond: 4 # 正常语速
|
||||
fastCharPerSecond: 5 # 情绪激动时(愤怒、紧张、兴奋、紧迫),对应台词中含大量!?或短促句式
|
||||
slowCharPerSecond: 3 # 情绪低落时(悲伤、沉思、犹豫),对应台词中含……唉等拖沓语气
|
||||
|
||||
# 各内容类型的画风/镜间质量/防崩坏约束及镜头时长规则(生成脚本时注入 prompt)
|
||||
shotDuration:
|
||||
# ==================== 短剧 ====================
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
|
||||
1. **基础动作上限2秒**:单个物理动作(跑、跳、砸、摔、瞪眼、拔刀、推门、转身、蹲下等)对应的镜头时长不得超过2秒。如果一个场景中连续发生了多个动作,必须拆分成多个镜头,每个镜头1-2秒。
|
||||
|
||||
2. **对话镜头上限3秒**:纯对话或反应镜头最长3秒。
|
||||
2. **对话镜头时长由台词长度决定**:对话或反应镜头的时长必须能容纳台词和旁白配音,参考中文语速约4-5字/秒。例如一段15字的台词至少需要4秒。**本条优先级高于基础动作2秒规则**。
|
||||
|
||||
3. **1秒原则**:震惊、回头、对视、抬手等简单反应或微表情,直接用1秒。不要给这些内容分配2秒以上。
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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 列补充完成")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=每次视频)"`
|
||||
|
||||
@@ -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=每次视频)"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user