1
This commit is contained in:
@@ -75,16 +75,86 @@
|
||||
- **丰富而非删减**:在保留全部原有细节的基础上,可以增加合理的微观动作、反应表情、环境互动来让每个镜头更丰满。不要删除任何已有内容来"精简"。
|
||||
- **禁止概括**:如果你发现自己把一个具体的动作写成了概括性的描述,说明你在删减。请回到剧情描述中把具体细节还原出来。
|
||||
|
||||
### 旁白与主台词区分
|
||||
### 三大字段的严格区分
|
||||
|
||||
每个镜头必须严格区分以下两种内容:
|
||||
- **旁白(narration)**:画外音解说,以第三人称描述场景背景、角色状态、时间地点等。旁白不是角色说的话,而是解说性的叙述文字。
|
||||
- **主台词(dialogue)**:角色在画面中亲口说出的对白和台词。如果有多个角色对话,请标注角色名。
|
||||
- 短剧需要同时有旁白解说和角色对白来推动剧情,两者缺一不可。
|
||||
每个镜头的 JSON 中有三个文本字段:`event`(画面描述)、`narration`(旁白配音)、`dialogue`(角色台词)。**它们的用途完全不同,严禁混用:**
|
||||
|
||||
### 节奏要求
|
||||
短剧节奏必须紧凑明快,遵循以下原则:
|
||||
1. **快速推进**:每1-3秒内必须有新的情节信息、动作、对白或事件转折,不能让观众感到内容稀疏。
|
||||
2. **高事件密度**:将剧情描述中的情节密集地分配到时间线上,确保每一秒都有实质内容,每个镜头必须有明确的情节日地。
|
||||
3. **避免松散**:镜头切换要频繁,单个镜头不宜过长。如果一个镜头超过8秒还没有新信息出现,说明太松散,请拆分或加速节奏。
|
||||
4. **拒绝空镜头**:不要为了凑时长而加入无情节推进作用的过渡性镜头,每一帧都要服务于叙事。
|
||||
| 字段 | 用途 | 是否会配音 | 示例 |
|
||||
|------|------|-----------|------|
|
||||
| `event` | **画面描述**——观众在屏幕上直接看到的一切:场景环境、角色动作、表情变化、物体位置等。这是视频模型生成画面的唯一依据,必须详细准确。 | 否 | "他落在夜市街上,荧光冲锋衣在人群里格外扎眼" |
|
||||
| `narration` | **旁白配音**——需要配音演员念出来的解说文字,如角色内心独白、故事背景交代、时间跳跃说明等。**必须是不能通过画面直接传达的信息。** | 是 | "虾仁万万没想到,一个外卖竟然改变了他的一生" |
|
||||
| `dialogue` | **角色台词**——角色在画面中亲口说出的对白。多人对话用「角色名:台词」格式。 | 是 | "拿下" |
|
||||
|
||||
**核心判断方法:**
|
||||
- 如果这段文字描述的是观众能直接看到的画面内容(谁做了什么、在哪里、环境什么样)→ 放入 `event`,**严禁放入 `narration`**
|
||||
- 只有在"画面上看不到这段内容,但需要配音解说才能让观众理解"时 → 才放入 `narration`
|
||||
|
||||
**常见错误**(禁止):
|
||||
```
|
||||
错误1:把画面描述放入旁白
|
||||
narration: "他落在夜市街上,荧光冲锋衣在人群里格外扎眼" ← 这是画面描述,应放入event
|
||||
event: "" ← event为空,视频模型缺少画面依据
|
||||
|
||||
错误2:同样内容写在两处
|
||||
narration: "他落在夜市街上" ← 画面描述不应是旁白
|
||||
event: "他落在夜市街上" ← 同一内容写两遍
|
||||
|
||||
正确示范:
|
||||
event: "他落在夜市街上,荧光冲锋衣在人群里格外扎眼" ← 画面描述归event
|
||||
narration: "" ← 纯视觉内容不需要配音旁白
|
||||
dialogue: "" ← 本镜头无人说话
|
||||
```
|
||||
|
||||
**短剧需要旁白解说和角色对白来推动剧情,但旁白必须是有配音价值的内容(角色内心想法、背景说明等),不能把画面描述放进旁白凑字数。**
|
||||
|
||||
### 对话保真要求
|
||||
|
||||
- 剧情描述中出现的每一句角色说的话,**必须原样保留**在对应镜头的 `dialogue` 字段里,一字不差。
|
||||
- 严禁把对话内容"翻译"成事件描述。例如剧情描述写了千户说"拿下",则镜头中的 `dialogue` 字段必须是"拿下"二字,而不是在 `event` 里写成"千户挥手命令手下上前"。
|
||||
|
||||
### 节奏要求——每帧必有事
|
||||
|
||||
短剧节奏必须极其紧凑,遵循以下硬性规则:
|
||||
|
||||
1. **基础动作上限2秒**:单个物理动作(跑、跳、砸、摔、瞪眼、拔刀、推门、转身、蹲下等)对应的镜头时长不得超过2秒。如果一个场景中连续发生了多个动作,必须拆分成多个镜头,每个镜头1-2秒。
|
||||
|
||||
2. **对话镜头上限3秒**:纯对话或反应镜头最长3秒。
|
||||
|
||||
3. **1秒原则**:震惊、回头、对视、抬手等简单反应或微表情,直接用1秒。不要给这些内容分配2秒以上。
|
||||
|
||||
4. **拆解而非合并**:当剧情描述中一行话包含多个动作(例如"虾仁抄起酒壶砸过去,转身跳窗就跑"),必须拆分为至少两个镜头——砸酒壶1-2秒,跳窗1-2秒——而不是合并成一个5秒的镜头。
|
||||
|
||||
5. **整体密度**:50秒的剧集至少包含20-25个镜头。每个镜头必须推进情节,不允许存在单纯为了过渡或凑时长的空镜头。如果25个镜头装不下所有剧情,宁可缩短每个镜头时长也不能删除任何剧情细节。
|
||||
|
||||
### 镜头时长分配示例
|
||||
|
||||
```
|
||||
正确(拆解密集):
|
||||
镜头1:虾仁抄起酒壶(1秒)
|
||||
镜头2:猛砸向锦衣卫(1秒)
|
||||
镜头3:转身跳窗(1秒)
|
||||
镜头4:落到夜市街上(1秒)
|
||||
→ 4个镜头4秒,信息密度高
|
||||
|
||||
错误(合并松散):
|
||||
镜头1:虾仁抄起酒壶砸向锦衣卫然后跳窗跑到夜市(5秒)
|
||||
→ 1个镜头5秒,信息密度低
|
||||
|
||||
### 风控规避——安全合规生产
|
||||
|
||||
视频模型对某些画面有安全审查机制。生成镜头描述时,必须主动规避以下内容,否则视频生成会失败或画面崩坏:
|
||||
|
||||
1. **政治敏感零容忍**:禁止出现真实政治人物、政治标志和符号、当代军警标识和制服。
|
||||
2. **暴力血腥弱化**:刀刺入身体、血溅、断肢、内脏特写等直接伤害画面,改为"切镜回避"或"反应镜头"(切到他人震惊表情,不展示伤害过程)。用人物反应和声音效果代替直接画面。
|
||||
3. **当代执法场景模糊化**:当代背景中的军警制服+抓捕动作可能触发审核,将"警察/武警"改为"保安/黑衣人"等中性身份,动作从"按倒铐住"改为"围住请离"。
|
||||
4. **武器描写聚焦人物而非武器**:写"他握紧长刀,目光凌厉"而不写"刀刃滴血劈砍"。武器对峙用中远景,回避武器接触身体的描写。
|
||||
5. **暴力结果用间接描写**:有人倒地后,不写伤口血迹,写周围人的反应或切远景。不描写尸体细节。
|
||||
6. **性暗示禁止**:不得出现裸露、床戏、抚摸敏感部位等描写。
|
||||
|
||||
改写示例:
|
||||
- 原:「他一刀捅进对方腹部,鲜血喷涌」→ 改:「他挥刀相向,对方闪避后退,镜头切走」
|
||||
- 原:「一拳打在脸上鼻血直流」→ 改:「一拳挥出,对方踉跄倒地」
|
||||
- 原:「警察冲进来按住他戴上手铐」→ 改:「几名黑衣人上前将他带离」
|
||||
|
||||
原则:写"发生了什么"的语境和反应,不写"怎么发生的"的物理细节。用镜头语言(切镜、远景、遮挡、光影)代替血腥描写。
|
||||
```
|
||||
Binary file not shown.
@@ -81,6 +81,11 @@ func init() {
|
||||
`ALTER TABLE `+public.TableNameGenerationTask+` ADD COLUMN duration INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
g.Log().Debugf(ctx, "添加 duration 列失败(可能已存在): %v", err)
|
||||
}
|
||||
// 迁移:添加 model_name 列
|
||||
if _, err := g.DB().Exec(ctx,
|
||||
`ALTER TABLE `+public.TableNameGenerationTask+` ADD COLUMN model_name TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
g.Log().Debugf(ctx, "添加 model_name 列失败(可能已存在): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *generationTaskDao) Insert(ctx context.Context, data *entity.GenerationTask) (id int64, err error) {
|
||||
|
||||
@@ -28,14 +28,21 @@ func ShotsToText(shots []Shot) string {
|
||||
}
|
||||
for _, s := range shots {
|
||||
fmt.Fprintf(&b, "【镜头%d】(%s-%s)\n", s.Index, s.StartTime, s.EndTime)
|
||||
if s.Narration != "" {
|
||||
fmt.Fprintf(&b, "旁白:%s\n", s.Narration)
|
||||
}
|
||||
if s.Dialogue != "" {
|
||||
fmt.Fprintf(&b, "台词:%s\n", s.Dialogue)
|
||||
}
|
||||
if s.Event != "" {
|
||||
fmt.Fprintf(&b, "事件:%s\n", s.Event)
|
||||
// event 是视频模型的画面依据,把 narration 内容合并到 event 中,
|
||||
// 防止 AI 误把画面描述写进 narration 导致视频模型缺少画面依据
|
||||
eventText := s.Event
|
||||
if s.Narration != "" {
|
||||
if eventText != "" {
|
||||
eventText = s.Narration + "。" + eventText
|
||||
} else {
|
||||
eventText = s.Narration
|
||||
}
|
||||
}
|
||||
if eventText != "" {
|
||||
fmt.Fprintf(&b, "事件:%s\n", eventText)
|
||||
}
|
||||
if s.CameraMovement != "" {
|
||||
fmt.Fprintf(&b, "运镜:%s\n", s.CameraMovement)
|
||||
|
||||
@@ -13,6 +13,7 @@ type GenerationTask struct {
|
||||
VideoTaskId string `orm:"video_task_id" json:"videoTaskId" dc:"视频合成任务ID"`
|
||||
VideoUrl string `orm:"video_url" json:"videoUrl" dc:"视频文件路径"`
|
||||
NumSegments int `orm:"num_segments" json:"numSegments" dc:"本集总段数"`
|
||||
ModelName string `orm:"model_name" json:"modelName" dc:"模型名称"`
|
||||
ErrorMessage string `orm:"error_message" json:"errorMessage" dc:"错误信息"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间"`
|
||||
|
||||
@@ -275,6 +275,17 @@ func (s *configService) GetMergedConfig(ctx context.Context, userId int64, model
|
||||
return merged
|
||||
}
|
||||
|
||||
// 用户配置了特定模型 → 使用该模型本身的系统配置(ModelName、Schema 等)
|
||||
if userCfg.ModelConfigId > 0 {
|
||||
list := s.getModelList(ctx)
|
||||
for _, m := range list {
|
||||
if m.Id == userCfg.ModelConfigId {
|
||||
merged.ModelConfig = m
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用户 API Key / 端点地址 + 接口路径覆盖系统配置
|
||||
if userCfg.ApiKey != "" {
|
||||
merged.ApiKey = userCfg.ApiKey
|
||||
|
||||
@@ -295,11 +295,11 @@ func (s *dramaService) GenerateEpisode(ctx context.Context, dramaId, epId int64,
|
||||
if mode == "serial" {
|
||||
// 串行生成:逐段串行(图生视频支持尾帧参考)
|
||||
g.Log().Infof(ctx, "串行模式:逐段生成 %d 个片段", numSegments)
|
||||
var prevVideoURL string
|
||||
var prevFirstFramePath string
|
||||
for i, segDur := range segDurs {
|
||||
taskId := taskIds[i]
|
||||
g.Log().Infof(genCtx, "第%d集第%d段开始串行生成(segDur=%ds) firstFrame=%q", ep.Index, i+1, segDur, prevVideoURL)
|
||||
if err := s.generateOneSegment(genCtx, d, ep, taskId, i, segDur, "", genCtx2, prevVideoURL); err != nil {
|
||||
g.Log().Infof(genCtx, "第%d集第%d段开始串行生成(segDur=%ds) firstFrame=%q", ep.Index, i+1, segDur, prevFirstFramePath)
|
||||
if err := s.generateOneSegment(genCtx, d, ep, taskId, i, segDur, "", genCtx2, prevFirstFramePath); err != nil {
|
||||
g.Log().Errorf(genCtx, "第%d集第%d段串行生成失败: %v", ep.Index, i+1, err)
|
||||
_ = dao.GenerationTask.UpdateFailed(genCtx, taskId, err.Error())
|
||||
clearPollCache(genCtx, epId)
|
||||
@@ -317,9 +317,13 @@ func (s *dramaService) GenerateEpisode(ctx context.Context, dramaId, epId int64,
|
||||
g.Log().Errorf(genCtx, "等待第%d段视频完成失败: %v", i+1, err)
|
||||
return err
|
||||
}
|
||||
// 获取上一段生成的视频URL,作为下一段的首帧参考
|
||||
if t, _ := dao.GenerationTask.GetOne(genCtx, taskId); t != nil && t.VideoUrl != "" {
|
||||
prevVideoURL = t.VideoUrl
|
||||
// 获取上一段生成的视频,提取尾帧作为下一段的首帧参考
|
||||
// 如果下一段的 task.script 中已有首帧 key,则跳过截取
|
||||
nextTask, _ := dao.GenerationTask.GetOne(genCtx, taskIds[i+1])
|
||||
if nextTask != nil && hasFirstFrameInScript(nextTask.Script) {
|
||||
g.Log().Infof(genCtx, "第%d段task.script已有首帧key,跳过尾帧截取", i+2)
|
||||
} else if t, _ := dao.GenerationTask.GetOne(genCtx, taskId); t != nil && t.VideoUrl != "" {
|
||||
prevFirstFramePath = getOrCreateLastFrame(genCtx, t.VideoUrl, d.Title, ep.Title, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,7 +501,10 @@ func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama,
|
||||
g.Log().Infof(ctx, "第%d段视频参考素材: %d个(角色%d 场景%d 道具%d)",
|
||||
segIdx+1, len(videoRefs), len(segOutput.Characters), len(seenScene), len(seenProp))
|
||||
|
||||
taskID, _, submitErr := s.submitVideoTask(ctx, d, ep, segIdx, segDur, segOutput.Scenes, videoRefs, firstFrameURL)
|
||||
// 调用模型前写入模型名称
|
||||
_ = dao.GenerationTask.UpdateFields(ctx, taskId, g.Map{"model_name": videoCfg.ModelName})
|
||||
|
||||
taskID, requestJSON, submitErr := s.submitVideoTask(ctx, d, ep, segIdx, segDur, segOutput.Scenes, videoRefs, firstFrameURL)
|
||||
if submitErr != nil {
|
||||
g.Log().Errorf(ctx, "第%d段视频提交失败: %v", segIdx+1, submitErr)
|
||||
return submitErr
|
||||
@@ -505,9 +512,10 @@ func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama,
|
||||
g.Log().Infof(ctx, "第%d集第%d段视频已提交(taskId=%s, duration=%ds)", ep.Index, segIdx+1, taskID, segDur)
|
||||
}
|
||||
|
||||
// 只更新 video_task_id 和 num_segments,不覆盖 script(保留 createPendingTasks 的原始 bodyJSON)
|
||||
// 更新 video_task_id、num_segments 和 script(含首帧参考)
|
||||
updateFields := g.Map{
|
||||
"num_segments": numSegments,
|
||||
"script": requestJSON,
|
||||
"updated_at": nil,
|
||||
}
|
||||
if taskID != "" {
|
||||
@@ -873,6 +881,18 @@ func (s *dramaService) FeedbackSegment(ctx context.Context, taskId int64, feedba
|
||||
go func() {
|
||||
genCtx := context.Background()
|
||||
genCtx = agent.WithDramaID(genCtx, d.Id)
|
||||
|
||||
// 删除旧视频文件
|
||||
if task.VideoUrl != "" {
|
||||
if err := os.Remove(task.VideoUrl); err != nil && !os.IsNotExist(err) {
|
||||
g.Log().Warningf(genCtx, "删除旧视频文件失败: %v", err)
|
||||
} else if err == nil {
|
||||
g.Log().Infof(genCtx, "已删除旧视频文件: %s", task.VideoUrl)
|
||||
}
|
||||
}
|
||||
// 清除 DB 中的 video_url,防止轮询器使用旧数据
|
||||
_ = dao.GenerationTask.UpdateFields(genCtx, taskId, g.Map{"video_url": "", "video_task_id": ""})
|
||||
|
||||
g.Log().Infof(genCtx, "第%d集第%d段重新提交视频(跳过Agent)", ep.Index, task.SegmentIdx+1)
|
||||
|
||||
// 将 feedback 追加到 task.Script 的 prompt 末尾,不覆盖原有内容
|
||||
@@ -892,6 +912,9 @@ func (s *dramaService) FeedbackSegment(ctx context.Context, taskId int64, feedba
|
||||
}
|
||||
}
|
||||
|
||||
// 调用模型前写入模型名称
|
||||
_ = dao.GenerationTask.UpdateFields(genCtx, taskId, g.Map{"model_name": modelCfg.ModelName})
|
||||
|
||||
newTaskID, resolvedBody, submitErr := resubmitVideoTask(genCtx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.Schema, bodyBytes)
|
||||
if submitErr != nil {
|
||||
g.Log().Errorf(genCtx, "第%d集第%d段重新提交视频失败: %v", ep.Index, task.SegmentIdx+1, submitErr)
|
||||
@@ -1141,14 +1164,18 @@ func (s *dramaService) processOneEpisode(ctx context.Context, tasks []*entity.Ge
|
||||
|
||||
// ====== 分支 B:无 video_task_id → 需要提交(含首次提交和崩溃恢复)======
|
||||
|
||||
// 从 DB 查前一段是否已完成,并获取首帧参考 URL
|
||||
// 从 DB 查前一段是否已完成,并获取首帧参考(尾帧图片文件)
|
||||
firstFrameURL := ""
|
||||
if current.SegmentIdx > 0 {
|
||||
if current.SegmentIdx > 0 && !hasFirstFrameInScript(current.Script) {
|
||||
prev := findTaskBySegmentIdx(ctx, current.EpisodeId, current.SegmentIdx-1)
|
||||
if prev == nil || prev.VideoUrl == "" {
|
||||
return
|
||||
}
|
||||
firstFrameURL, _ = extractLastFrame(ctx, prev.VideoUrl)
|
||||
// 加载短剧和剧集信息用于 getOrCreateLastFrame
|
||||
firstFrameEp, _ := dao.Episode.GetOne(ctx, current.EpisodeId)
|
||||
if firstFrameEp != nil {
|
||||
firstFrameURL = getOrCreateLastFrame(ctx, prev.VideoUrl, drama.Title, firstFrameEp.Title, prev.SegmentIdx)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 task.script(bodyJSON)直接提交,不走 Agent
|
||||
@@ -1181,6 +1208,9 @@ func (s *dramaService) processOneEpisode(ctx context.Context, tasks []*entity.Ge
|
||||
|
||||
bodyJSON, _ := json.Marshal(bodyMap)
|
||||
|
||||
// 调用模型前写入模型名称
|
||||
_ = dao.GenerationTask.UpdateFields(ctx, current.Id, g.Map{"model_name": modelCfg.ModelName})
|
||||
|
||||
taskId, _, err := resubmitVideoTask(ctx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.Schema, bodyJSON)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "轮询器: 第%d段视频提交失败: %v", current.SegmentIdx+1, err)
|
||||
@@ -1504,6 +1534,10 @@ func createVideoTask(ctx context.Context, apiKey, baseURL, modelName, prompt, ne
|
||||
if seed > 0 {
|
||||
setSchemaField(body, "seed", seed)
|
||||
}
|
||||
// 首帧参考:注入上一段尾帧图片路径
|
||||
if firstFrameURL != "" {
|
||||
injectFirstFrame(body, videoSchema, firstFrameURL)
|
||||
}
|
||||
// 尺寸映射:从 drama 的 Resolution/AspectRatio 查 video_schema
|
||||
if sizes := nested(vs, "body", "parameters", "size", "sizes"); sizes != nil {
|
||||
if sm, ok := sizes.(map[string]any); ok {
|
||||
@@ -1515,8 +1549,13 @@ func createVideoTask(ctx context.Context, apiKey, baseURL, modelName, prompt, ne
|
||||
|
||||
payload, _ := json.Marshal(body)
|
||||
promptStr, _ := getSchemaField(body, "prompt").(string)
|
||||
g.Log().Infof(ctx, "视频API请求 model=%s body_size=%d prompt_len=%d 目的=提交视频生成任务", modelName, len(payload), len([]rune(promptStr)))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", baseURL, strings.NewReader(string(payload)))
|
||||
|
||||
// 将 body 中的本地文件路径转为 base64,用于 API 请求(不修改 payload,保留文件路径给 task.script)
|
||||
convertBodyMediaToBase64(body)
|
||||
apiPayload, _ := json.Marshal(body)
|
||||
|
||||
g.Log().Infof(ctx, "视频API请求 model=%s body_size=%d prompt_len=%d 目的=提交视频生成任务", modelName, len(apiPayload), len([]rune(promptStr)))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", baseURL, strings.NewReader(string(apiPayload)))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
@@ -1564,60 +1603,25 @@ func resubmitVideoTask(ctx context.Context, apiKey, baseURL, schema string, body
|
||||
return "", nil, fmt.Errorf("解析 bodyJSON 失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析 reference_urls/media 中的文件路径:http/https/data: 保持原样,否则转 base64
|
||||
if input, ok := bodyMap["input"].(map[string]any); ok {
|
||||
// reference_urls 字符串数组
|
||||
if refs, ok := input["reference_urls"].([]any); ok {
|
||||
resolved := make([]any, len(refs))
|
||||
for i, r := range refs {
|
||||
u, _ := r.(string)
|
||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "data:") {
|
||||
resolved[i] = u
|
||||
} else if b64, err := imageFileToBase64(u); err == nil {
|
||||
resolved[i] = b64
|
||||
} else {
|
||||
g.Log().Warningf(ctx, "reference_urls 元素无法解析: %s", u)
|
||||
resolved[i] = u
|
||||
}
|
||||
}
|
||||
input["reference_urls"] = resolved
|
||||
}
|
||||
// media 对象数组,转 url 字段
|
||||
if media, ok := input["media"].([]any); ok {
|
||||
for _, item := range media {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
u, _ := m["url"].(string)
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "data:") {
|
||||
continue
|
||||
}
|
||||
if b64, err := imageFileToBase64(u); err == nil {
|
||||
m["url"] = b64
|
||||
} else {
|
||||
g.Log().Warningf(ctx, "media url 无法解析: %s", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 保存原始 bodyJSON 用于返回(task.script 使用文件路径)
|
||||
origPayload := bodyJSON
|
||||
|
||||
// 将 body 中的本地文件路径转为 base64,用于 API 请求
|
||||
convertBodyMediaToBase64(bodyMap)
|
||||
apiPayload, _ := json.Marshal(bodyMap)
|
||||
|
||||
payload, _ := json.Marshal(bodyMap)
|
||||
// 提取模型名用于日志
|
||||
modelName := ""
|
||||
{
|
||||
var mn struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &mn); err == nil {
|
||||
if err := json.Unmarshal(apiPayload, &mn); err == nil {
|
||||
modelName = mn.Model
|
||||
}
|
||||
}
|
||||
g.Log().Infof(ctx, "视频API重新提交 model=%s body_size=%d 目的=重新提交视频生成任务或崩溃恢复", modelName, len(payload))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", baseURL, strings.NewReader(string(payload)))
|
||||
g.Log().Infof(ctx, "视频API重新提交 model=%s body_size=%d 目的=重新提交视频生成任务或崩溃恢复", modelName, len(apiPayload))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", baseURL, strings.NewReader(string(apiPayload)))
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
@@ -1655,7 +1659,7 @@ func resubmitVideoTask(ctx context.Context, apiKey, baseURL, schema string, body
|
||||
if taskID == "" {
|
||||
return "", nil, fmt.Errorf("无法从响应中获取任务ID: %s", string(respData))
|
||||
}
|
||||
return taskID, payload, nil
|
||||
return taskID, origPayload, nil
|
||||
}
|
||||
|
||||
// resolveSizeFromSchema 从 video_schema.sizes 中根据分辨率和宽高比查找尺寸字符串
|
||||
@@ -1716,6 +1720,44 @@ func schemaHeaders(schemaStr string) map[string]string {
|
||||
return headers
|
||||
}
|
||||
|
||||
// convertBodyMediaToBase64 将 body 中媒体文件的本地路径转为 base64(原地修改)
|
||||
// 覆盖 reference_urls(字符串数组)和 media(对象数组的 url 字段)
|
||||
func convertBodyMediaToBase64(body map[string]any) {
|
||||
input, ok := body["input"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// reference_urls 字符串数组
|
||||
if refs, ok := input["reference_urls"].([]any); ok {
|
||||
for i, r := range refs {
|
||||
u, _ := r.(string)
|
||||
if u == "" || strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "data:") {
|
||||
continue
|
||||
}
|
||||
if b64, err := imageFileToBase64(u); err == nil {
|
||||
refs[i] = b64
|
||||
}
|
||||
}
|
||||
input["reference_urls"] = refs
|
||||
}
|
||||
// media 对象数组,转 url 字段
|
||||
if media, ok := input["media"].([]any); ok {
|
||||
for _, item := range media {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
u, _ := m["url"].(string)
|
||||
if u == "" || strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "data:") {
|
||||
continue
|
||||
}
|
||||
if b64, err := imageFileToBase64(u); err == nil {
|
||||
m["url"] = b64
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// injectFirstFrame 从 model_config.schema 解析首帧字段,按正确格式注入
|
||||
// 1. items.properties.type.enum 含 first_frame → 追加 {type:"first_frame", url:...}
|
||||
// 2. reference_urls 字符串数组 → 前置插入 URL
|
||||
@@ -1956,6 +1998,15 @@ func (s *dramaService) mergeEpisodeVideo(ctx context.Context, epId int64, lastTa
|
||||
}
|
||||
g.Log().Infof(ctx, "已清理 %d 个分段视频文件", len(segFiles))
|
||||
|
||||
// 清理首尾帧图片(lastframe_*.jpg / *_lastframe.jpg)
|
||||
lastFrameGlob := filepath.Join(outputDir, fmt.Sprintf("%s_seg_*_lastframe.jpg", safeEp))
|
||||
if matches, err := filepath.Glob(lastFrameGlob); err == nil && len(matches) > 0 {
|
||||
for _, f := range matches {
|
||||
os.Remove(f)
|
||||
}
|
||||
g.Log().Infof(ctx, "已清理 %d 个首尾帧图片文件", len(matches))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2036,6 +2087,88 @@ func extractLastFrame(ctx context.Context, videoPath string) (string, error) {
|
||||
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
// saveLastFrameToFile 从视频文件中提取尾帧保存为图片文件
|
||||
func saveLastFrameToFile(ctx context.Context, videoPath, outputPath string) error {
|
||||
if _, err := os.Stat(videoPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("视频文件不存在: %s", videoPath)
|
||||
}
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return fmt.Errorf("ffmpeg 不可用: %w", err)
|
||||
}
|
||||
dir := filepath.Dir(outputPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-sseof", "-0.1",
|
||||
"-i", videoPath,
|
||||
"-q:v", "2",
|
||||
"-vframes", "1",
|
||||
"-y", outputPath,
|
||||
)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("ffmpeg 截取尾帧失败: %w, 输出: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getOrCreateLastFrame 获取上一段视频的尾帧图片,如果不存在则用 FFMPEG 截取
|
||||
// 返回图片文件路径,失败返回空字符串(供当前段作为首帧参考)
|
||||
func getOrCreateLastFrame(ctx context.Context, videoPath, dramaTitle, epTitle string, segIdx int) string {
|
||||
if videoPath == "" {
|
||||
return ""
|
||||
}
|
||||
safeEp := sanitizeDirName(epTitle)
|
||||
lastFrameName := fmt.Sprintf("%s_seg_%d_lastframe.jpg", safeEp, segIdx)
|
||||
|
||||
// 与视频文件放在同一目录
|
||||
frameDir := filepath.Dir(videoPath)
|
||||
if frameDir == "." {
|
||||
wsDir := WorkspaceDir(dramaTitle)
|
||||
frameDir = filepath.Join(wsDir, "产出视频")
|
||||
}
|
||||
|
||||
framePath := filepath.Join(frameDir, lastFrameName)
|
||||
|
||||
// 已存在则直接返回
|
||||
if _, err := os.Stat(framePath); err == nil {
|
||||
return framePath
|
||||
}
|
||||
|
||||
// 不存在则用 FFMPEG 截取
|
||||
if err := saveLastFrameToFile(ctx, videoPath, framePath); err != nil {
|
||||
g.Log().Warningf(ctx, "截取尾帧失败: %v", err)
|
||||
return ""
|
||||
}
|
||||
g.Log().Infof(ctx, "已截取尾帧: %s -> %s", videoPath, framePath)
|
||||
return framePath
|
||||
}
|
||||
|
||||
// hasFirstFrameInScript 检查 task.script 的 bodyJSON 中是否已包含首帧引用(media 数组中 type=first_frame)
|
||||
func hasFirstFrameInScript(script string) bool {
|
||||
if script == "" {
|
||||
return false
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(script), &body); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, v := range body {
|
||||
if section, ok := v.(map[string]any); ok {
|
||||
if media, ok := section["media"].([]any); ok {
|
||||
for _, item := range media {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
if t, _ := m["type"].(string); t == "first_frame" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// waitForSegmentVideo 等待指定任务的视频生成完成。
|
||||
// 轮询视频 API 直到视频就绪,下载到本地,更新 DB 的 video_url。
|
||||
// 用于串行模式中让下一段能提取上一段的尾帧作为首帧。
|
||||
|
||||
@@ -342,7 +342,7 @@ func (s *dramaService) buildScriptGenUserInput(d *entity.Drama, episodeTitle, de
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("【要求】\n请根据以上剧情描述,为当前剧集创作一份详细的剧本。剧情描述是核心创作依据,请严格围绕其内容展开。\n\n输出格式:JSON数组,每个元素是一个镜头对象(shot),包含以下字段:\n- index: 镜头序号(从1开始)\n- startTime: 开始时间(格式MM:SS)\n- endTime: 结束时间(格式MM:SS)\n- event: 纯画面描述,只写观众在屏幕上能看到的内容——场景环境、角色动作、表情变化、镜头运动等。不包含任何对白和旁白。这是视频模型的画面依据,必须详细准确。\n- dialogue: 主台词(角色亲口说出口的对白),如角色A说「你好」,角色B说「再见」。多人对话可用A:...B:...格式。旁白和内心独白不属于这里。\n- narration: 画外音(旁白/内心独白),必须是需要配音念出来的文字,如角色内心想法、旁白解说。**不要写画面描述**,画面内容请放到 event 字段。如果本镜头没有需要配音念出来的文字,留空即可。\n- cameraMovement: 运镜方式,从以下标准类型中选择一种:固定镜头、推、拉、摇、移、跟、升、降、旋转、晃动、航拍\n- characters: 出演人物数组,填写演员名称,如[\"张三\", \"李四\"],从「可用演员」中选择\n- scene: 场景名称,从「可用场景」中选择\n- props: 道具名称数组,如[\"剑\", \"酒杯\"],从「可用道具」中选择\n\n直接输出JSON数组,不要markdown代码块标记,不要其他任何内容。\n\n要求:\n1. 剧本必须紧扣剧情描述展开\n2. 所有镜头时长之和应等于 %d 秒(每个镜头%d-%d秒不等)\n3. 优先使用提供的演员、场景和道具,如需新增请合理创作\n4. 每个镜头的characters、scene、props字段必须从提供的可用列表中选取名称,确保与参考素材一致\n5. **严格区分旁白和主台词:narration是画外音(描述场景/角色),dialogue是角色在画面中说出口的对话。不要混淆两者。短剧需要同时有旁白解说和角色对白来推动剧情。**\n6. **节奏紧凑:剧情推进要快速有力,避免拖沓。每%d-%d秒内必须有新的情节信息、动作、对白或事件转折,不能让观众感到内容稀疏。**\n7. **事件密度:将剧情描述中的情节密集地分配到时间线上,确保每一秒都有实质内容。每个镜头必须有明确的情节目的,删除所有无信息量的过渡性空镜头。**\n8. **避免松散:镜头切换要频繁,单个镜头不宜过长。如果一个镜头超过%d秒还没有新信息出现,说明太松散,请拆分或加速节奏。**\n9. **禁止删减和泛化:剧情描述中的每一个具体细节(人物的动作、表情反应、对话、环境互动等)都必须忠实地保留在对应镜头的event、narration或dialogue字段中,不能省略、概括或改写为泛化描述。例如「百姓指指点点」是泛化,必须写出具体谁做了什么表情/动作。「衙役尝了说好」是泛化,必须写出闻香→咽口水→试探咬→瞳孔放大→猛咬→竖拇指→满嘴油光这整个递进过程。如果你发现在把原文细节丢掉,请停下来把细节还原回去。**\n10. **JSON格式要求:输出必须是合法的 JSON 数组,字符串值中若需使用引号请用「」代替 ASCII 双引号,确保 json.Unmarshal 能正确解析。**\n", d.EpisodeDuration, d.MinShotDuration, d.MaxShotDuration, d.MinShotDuration, d.MaxShotDuration, d.MaxShotDuration))
|
||||
b.WriteString(fmt.Sprintf("【要求】\n请根据以上剧情描述,为当前剧集创作一份详细的剧本。剧情描述是核心创作依据,请严格围绕其内容展开。\n\n输出格式:JSON数组,每个元素是一个镜头对象(shot),包含以下字段:\n- index: 镜头序号(从1开始)\n- startTime: 开始时间(格式MM:SS)\n- endTime: 结束时间(格式MM:SS)\n- event: 画面描述——观众在屏幕上直接看到的一切。场景环境、角色动作、表情变化、物体位置等。**这是视频模型生成画面的唯一依据,所有画面内容必须写在这里**,严禁把画面描述放入 narration 字段。不包含旁白和台词。\n- dialogue: 主台词——角色在画面中亲口说出的对白。多人对话用「角色名:台词」格式。旁白和内心独白不属于这里。如果本镜头无人说话,留空即可。\n- narration: 旁白配音——需要配音演员念出来的解说文字。必须是**通过画面无法直接传达**的信息(如角色内心独白、故事背景交代、时间跳跃说明等)。**如果一段文字描述的是观众能直接看到的画面内容,它就不属于旁白,必须放进 event 字段。** 本镜头无旁白时留空即可。\n- cameraMovement: 运镜方式,从以下标准类型中选择一种:固定镜头、推、拉、摇、移、跟、升、降、旋转、晃动、航拍\n- characters: 出演人物数组,填写演员名称,如[\"张三\", \"李四\"],从「可用演员」中选择\n- scene: 场景名称,从「可用场景」中选择\n- props: 道具名称数组,如[\"剑\", \"酒杯\"],从「可用道具」中选择\n\n直接输出JSON数组,不要markdown代码块标记,不要其他任何内容。\n\n要求:\n1. 剧本必须紧扣剧情描述展开\n2. 所有镜头时长之和应等于 %d 秒(50秒至少20个镜头,20-25个镜头为最佳密度)\n3. 优先使用提供的演员、场景和道具,如需新增请合理创作\n4. 每个镜头的characters、scene、props字段必须从提供的可用列表中选取名称,确保与参考素材一致\n5. **对话必须原样保留:剧情描述中出现的每一句角色说的话,必须一字不差地写入对应镜头的dialogue字段。严禁把台词\"翻译\"成事件描述。例如:剧情写了千户说\"拿下\",dialogue就必须是\"拿下\",而不是在event里写\"千户挥手命令\"。**\n6. **单个动作不超过2秒:跑、跳、砸、摔、拔刀、推门、转身、蹲下、回头、瞪眼等单个物理动作,对应镜头时长不得超过2秒。连续发生的多个动作必须拆成多个镜头。**\n7. **纯对话镜头不超过3秒:只有说话和反应的场景,每个镜头的时长不超过3秒。用频繁切换说话人和反应镜头来保持节奏。**\n8. **1秒原则:震惊、回头、对视、抬手等简单反应或微表情,直接用1秒完成,不要拖长。**\n9. **拆解而非合并:剧情描述中一句话包含多个动作(如\"虾仁抄起酒壶砸过去,转身跳窗就跑\"),必须拆分为至少两个镜头(砸1-2秒,跳窗1-2秒),不能合并成一个5秒的长镜头。**\n10. **禁止删减和泛化:剧情描述中的每一个具体细节(人物的动作、表情反应、对话、环境互动等)都必须忠实地保留在对应镜头的event、narration或dialogue字段中,不能省略、概括或改写为泛化描述。例如「百姓指指点点」是泛化,必须写出具体谁做了什么表情/动作。「衙役尝了说好」是泛化,必须写出闻香→咽口水→试探咬→瞳孔放大→猛咬→竖拇指→满嘴油光这整个递进过程。如果你发现在把原文细节丢掉,请停下来把细节还原回去。**\n11. **JSON格式要求:输出必须是合法的 JSON 数组,字符串值中若需使用引号请用「」代替 ASCII 双引号,确保 json.Unmarshal 能正确解析。**\n12. **风控规避:视频模型对某些画面有安全审查机制,生成镜头描述时必须规避。政治敏感内容(真实政治人物/标志/当代军警标识)零容忍。暴力血腥画面(刀刺入身体、血溅、断肢)改为反应镜头或切镜回避,不展示伤害过程。当代背景中的军警抓捕动作将「警察/武警」改为「保安/黑衣人」,动作从「按倒铐住」改为「围住请离」。武器描写聚焦持武器的人物(「他握紧长刀,目光凌厉」),不写武器接触身体的画面。暴力结果用间接描写:写周围人反应而非伤口血迹。**\n", d.EpisodeDuration))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"video-factory/shortdrama/model/entity"
|
||||
)
|
||||
|
||||
// RefIndex 引用索引:{"演员":{"张三":"data:image/...;base64,..."}, "场景":{"..."}, "道具":{"..."}}
|
||||
// RefIndex 引用索引:{"演员":{"张三":"workspace/.../演员形象/张三.png"}, "场景":{"...", "道具":{"..."}}}
|
||||
type RefIndex map[string]map[string]string
|
||||
|
||||
// OrderedRef 带索引的引用,用于提示词中描述"张三使用参考图第N张"
|
||||
@@ -85,15 +85,10 @@ func (c *GenerationContext) buildRefIndex() error {
|
||||
if ch.PortraitPath == "" {
|
||||
continue
|
||||
}
|
||||
b64, err := imageFileToBase64(ch.PortraitPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if existing, ok := charIdx[ch.Name]; ok {
|
||||
return fmt.Errorf("演员名冲突: '%s' (已有形象路径 %s,重复 %s)", ch.Name, existing, ch.PortraitPath)
|
||||
}
|
||||
charIdx[ch.Name] = b64
|
||||
charIdx[ch.Name] = ch.PortraitPath
|
||||
}
|
||||
if len(charIdx) > 0 {
|
||||
c.RefIndex["演员"] = charIdx
|
||||
@@ -105,14 +100,10 @@ func (c *GenerationContext) buildRefIndex() error {
|
||||
if sc.ImagePath == "" {
|
||||
continue
|
||||
}
|
||||
b64, err := imageFileToBase64(sc.ImagePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := sceneIdx[sc.Name]; ok {
|
||||
return fmt.Errorf("场景名冲突: '%s'", sc.Name)
|
||||
}
|
||||
sceneIdx[sc.Name] = b64
|
||||
sceneIdx[sc.Name] = sc.ImagePath
|
||||
}
|
||||
if len(sceneIdx) > 0 {
|
||||
c.RefIndex["场景"] = sceneIdx
|
||||
@@ -124,14 +115,10 @@ func (c *GenerationContext) buildRefIndex() error {
|
||||
if p.ImagePath == "" {
|
||||
continue
|
||||
}
|
||||
b64, err := imageFileToBase64(p.ImagePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := propIdx[p.Name]; ok {
|
||||
return fmt.Errorf("道具名冲突: '%s'", p.Name)
|
||||
}
|
||||
propIdx[p.Name] = b64
|
||||
propIdx[p.Name] = p.ImagePath
|
||||
}
|
||||
if len(propIdx) > 0 {
|
||||
c.RefIndex["道具"] = propIdx
|
||||
|
||||
Reference in New Issue
Block a user