From 30d0b395f5feb0ffc43827ea10f9e8644f37df20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Sat, 18 Jul 2026 16:55:47 +0800 Subject: [PATCH] 1 --- prompt.md | 92 +++++++- short_drama.db | Bin 462848 -> 462848 bytes shortdrama/dao/generation_task_dao.go | 5 + shortdrama/model/domain/shot.go | 17 +- shortdrama/model/entity/generation_task.go | 1 + shortdrama/service/config_service.go | 11 + shortdrama/service/drama_service.go | 249 ++++++++++++++++----- shortdrama/service/episode_service.go | 2 +- shortdrama/service/generation_context.go | 21 +- 9 files changed, 306 insertions(+), 92 deletions(-) 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 aff41d4b933da705511dac54f49ba51c65a032cd..93ebb3301e240eaeae0e4a1b961fcce271c50e2f 100644 GIT binary patch delta 31694 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 Bq$dCX delta 24425 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 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