diff --git a/prompt.md b/prompt.md index 8f37f66..1864593 100644 --- a/prompt.md +++ b/prompt.md @@ -75,16 +75,86 @@ - **丰富而非删减**:在保留全部原有细节的基础上,可以增加合理的微观动作、反应表情、环境互动来让每个镜头更丰满。不要删除任何已有内容来"精简"。 - **禁止概括**:如果你发现自己把一个具体的动作写成了概括性的描述,说明你在删减。请回到剧情描述中把具体细节还原出来。 -### 旁白与主台词区分 +### 三大字段的严格区分 -每个镜头必须严格区分以下两种内容: -- **旁白(narration)**:画外音解说,以第三人称描述场景背景、角色状态、时间地点等。旁白不是角色说的话,而是解说性的叙述文字。 -- **主台词(dialogue)**:角色在画面中亲口说出的对白和台词。如果有多个角色对话,请标注角色名。 -- 短剧需要同时有旁白解说和角色对白来推动剧情,两者缺一不可。 +每个镜头的 JSON 中有三个文本字段:`event`(画面描述)、`narration`(旁白配音)、`dialogue`(角色台词)。**它们的用途完全不同,严禁混用:** -### 节奏要求 -短剧节奏必须紧凑明快,遵循以下原则: -1. **快速推进**:每1-3秒内必须有新的情节信息、动作、对白或事件转折,不能让观众感到内容稀疏。 -2. **高事件密度**:将剧情描述中的情节密集地分配到时间线上,确保每一秒都有实质内容,每个镜头必须有明确的情节日地。 -3. **避免松散**:镜头切换要频繁,单个镜头不宜过长。如果一个镜头超过8秒还没有新信息出现,说明太松散,请拆分或加速节奏。 -4. **拒绝空镜头**:不要为了凑时长而加入无情节推进作用的过渡性镜头,每一帧都要服务于叙事。 \ No newline at end of file +| 字段 | 用途 | 是否会配音 | 示例 | +|------|------|-----------|------| +| `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. **性暗示禁止**:不得出现裸露、床戏、抚摸敏感部位等描写。 + +改写示例: +- 原:「他一刀捅进对方腹部,鲜血喷涌」→ 改:「他挥刀相向,对方闪避后退,镜头切走」 +- 原:「一拳打在脸上鼻血直流」→ 改:「一拳挥出,对方踉跄倒地」 +- 原:「警察冲进来按住他戴上手铐」→ 改:「几名黑衣人上前将他带离」 + +原则:写"发生了什么"的语境和反应,不写"怎么发生的"的物理细节。用镜头语言(切镜、远景、遮挡、光影)代替血腥描写。 +``` \ No newline at end of file diff --git a/short_drama.db b/short_drama.db index aff41d4..93ebb33 100644 Binary files a/short_drama.db and b/short_drama.db differ diff --git a/shortdrama/dao/generation_task_dao.go b/shortdrama/dao/generation_task_dao.go index a40aa60..1cb0768 100644 --- a/shortdrama/dao/generation_task_dao.go +++ b/shortdrama/dao/generation_task_dao.go @@ -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) { diff --git a/shortdrama/model/domain/shot.go b/shortdrama/model/domain/shot.go index e128401..b0539a7 100644 --- a/shortdrama/model/domain/shot.go +++ b/shortdrama/model/domain/shot.go @@ -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) diff --git a/shortdrama/model/entity/generation_task.go b/shortdrama/model/entity/generation_task.go index 0aa14fb..8394260 100644 --- a/shortdrama/model/entity/generation_task.go +++ b/shortdrama/model/entity/generation_task.go @@ -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:"更新时间"` diff --git a/shortdrama/service/config_service.go b/shortdrama/service/config_service.go index 0b1e8a1..3d96566 100644 --- a/shortdrama/service/config_service.go +++ b/shortdrama/service/config_service.go @@ -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 diff --git a/shortdrama/service/drama_service.go b/shortdrama/service/drama_service.go index 76c56ab..31bef33 100644 --- a/shortdrama/service/drama_service.go +++ b/shortdrama/service/drama_service.go @@ -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。 // 用于串行模式中让下一段能提取上一段的尾帧作为首帧。 diff --git a/shortdrama/service/episode_service.go b/shortdrama/service/episode_service.go index 775442b..15fbd88 100644 --- a/shortdrama/service/episode_service.go +++ b/shortdrama/service/episode_service.go @@ -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() } diff --git a/shortdrama/service/generation_context.go b/shortdrama/service/generation_context.go index 9cb090a..970ea9d 100644 --- a/shortdrama/service/generation_context.go +++ b/shortdrama/service/generation_context.go @@ -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