From cbe868147f757f0875de2c8fa8b9a5c056680c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Tue, 7 Jul 2026 12:44:21 +0800 Subject: [PATCH] 1 --- shortdrama/controller/episode_controller.go | 12 +- shortdrama/dao/episode_dao.go | 5 + shortdrama/model/dto/episode_dto.go | 35 ++-- shortdrama/model/entity/episode.go | 21 +-- shortdrama/model/segment_output.go | 7 + shortdrama/service/drama_service.go | 183 +++++++++----------- shortdrama/service/episode_service.go | 155 ++++++++++++----- shortdrama/service/generation_context.go | 175 +++++++++++++++++++ shortdrama/service/workspace.go | 2 +- 9 files changed, 434 insertions(+), 161 deletions(-) create mode 100644 shortdrama/service/generation_context.go diff --git a/shortdrama/controller/episode_controller.go b/shortdrama/controller/episode_controller.go index 23069af..78440bd 100644 --- a/shortdrama/controller/episode_controller.go +++ b/shortdrama/controller/episode_controller.go @@ -17,7 +17,7 @@ func (c *drama) ListEpisode(ctx context.Context, req *dto.ListEpisodeReq) (res * } func (c *drama) AddEpisode(ctx context.Context, req *dto.AddEpisodeReq) (res *dto.GetDramaRes, err error) { - _, err = service.DramaService.AddEpisode(ctx, req.DramaId, req.Title, req.Script, req.Index) + _, err = service.DramaService.AddEpisode(ctx, req.DramaId, req.Title, req.Description, req.Script, req.Index) if err != nil { return nil, err } @@ -25,13 +25,21 @@ func (c *drama) AddEpisode(ctx context.Context, req *dto.AddEpisodeReq) (res *dt } func (c *drama) UpdateEpisode(ctx context.Context, req *dto.UpdateEpisodeReq) (res *dto.GetDramaRes, err error) { - err = service.DramaService.UpdateEpisode(ctx, req.DramaId, req.EpId, req.Title, req.Script, req.Index) + err = service.DramaService.UpdateEpisode(ctx, req.DramaId, req.EpId, req.Title, req.Description, req.Script, req.Index) if err != nil { return nil, err } return c.Get(ctx, &dto.GetDramaReq{Id: req.DramaId}) } +func (c *drama) GenerateScript(ctx context.Context, req *dto.GenerateScriptReq) (res *dto.GenerateScriptRes, err error) { + script, err := service.DramaService.GenerateScript(ctx, req.DramaId, req.Title, req.Description) + if err != nil { + return nil, err + } + return &dto.GenerateScriptRes{Script: script}, nil +} + func (c *drama) DeleteEpisode(ctx context.Context, req *dto.DeleteEpisodeReq) (res *dto.GetDramaRes, err error) { err = service.DramaService.DeleteEpisode(ctx, req.DramaId, req.EpId) if err != nil { diff --git a/shortdrama/dao/episode_dao.go b/shortdrama/dao/episode_dao.go index 35166b6..2d9c39f 100644 --- a/shortdrama/dao/episode_dao.go +++ b/shortdrama/dao/episode_dao.go @@ -39,6 +39,11 @@ func init() { g.Log().Warningf(ctx, "删除剧集表旧字段 %s 失败(可能已删除): %v", col, err) } } + // 迁移:添加 description 列 + if _, err := g.DB().Exec(ctx, + `ALTER TABLE `+public.TableNameEpisode+` ADD COLUMN description TEXT NOT NULL DEFAULT `); err != nil { + g.Log().Debugf(ctx, "添加 description 列失败(可能已存在): %v", err) + } } func (d *episodeDao) Insert(ctx context.Context, data *entity.Episode) (id int64, err error) { diff --git a/shortdrama/model/dto/episode_dto.go b/shortdrama/model/dto/episode_dto.go index af6d563..b801709 100644 --- a/shortdrama/model/dto/episode_dto.go +++ b/shortdrama/model/dto/episode_dto.go @@ -7,20 +7,22 @@ import ( ) type AddEpisodeReq struct { - g.Meta `path:"/episode/add" method:"post" tags:"短剧管理" summary:"添加剧集"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - Title string `v:"required" json:"title" dc:"剧集标题"` - Script string `json:"script" dc:"剧集脚本"` - Index int `json:"index" dc:"剧集序号"` + g.Meta `path:"/episode/add" method:"post" tags:"短剧管理" summary:"添加剧集"` + DramaId int64 `json:"dramaId" dc:"短剧ID"` + Title string `v:"required" json:"title" dc:"剧集标题"` + Description string `json:"description" dc:"剧情描述"` + Script string `json:"script" dc:"剧集脚本"` + Index int `json:"index" dc:"剧集序号"` } type UpdateEpisodeReq struct { - g.Meta `path:"/episode/update" method:"post" tags:"短剧管理" summary:"更新剧集"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - EpId int64 `json:"epId" dc:"剧集ID"` - Title string `v:"required" json:"title" dc:"剧集标题"` - Script string `json:"script" dc:"剧集脚本"` - Index int `json:"index" dc:"剧集序号"` + g.Meta `path:"/episode/update" method:"post" tags:"短剧管理" summary:"更新剧集"` + DramaId int64 `json:"dramaId" dc:"短剧ID"` + EpId int64 `json:"epId" dc:"剧集ID"` + Title string `v:"required" json:"title" dc:"剧集标题"` + Description string `json:"description" dc:"剧情描述"` + Script string `json:"script" dc:"剧集脚本"` + Index int `json:"index" dc:"剧集序号"` } type DeleteEpisodeReq struct { @@ -47,3 +49,14 @@ type EpisodePollRes struct { Tasks []*entity.GenerationTask `json:"tasks,omitempty" dc:"所有生成任务"` CurrentTaskId int64 `json:"currentTaskId,omitempty" dc:"当前审核任务ID"` } + +type GenerateScriptReq struct { + g.Meta `path:"/episode/generate-script" method:"post" tags:"短剧管理" summary:"生成剧集脚本"` + DramaId int64 `json:"dramaId" dc:"短剧ID"` + Title string `json:"title" dc:"剧集标题"` + Description string `json:"description" dc:"剧情描述"` +} + +type GenerateScriptRes struct { + Script string `json:"script" dc:"生成的脚本"` +} diff --git a/shortdrama/model/entity/episode.go b/shortdrama/model/entity/episode.go index 30a7ac9..9d8b4a1 100644 --- a/shortdrama/model/entity/episode.go +++ b/shortdrama/model/entity/episode.go @@ -3,14 +3,15 @@ package entity import "github.com/gogf/gf/v2/os/gtime" type Episode struct { - Id int64 `orm:"id" json:"id" dc:"剧集ID"` - DramaId int64 `orm:"drama_id" json:"dramaId" dc:"短剧ID"` - Index int `orm:"idx" json:"index" dc:"剧集序号"` - Title string `orm:"title" json:"title" dc:"剧集标题"` - Script string `orm:"script" json:"script" dc:"剧集脚本"` - Status string `orm:"status" json:"status" dc:"生成状态"` - VideoUrl string `orm:"video_url" json:"videoUrl" dc:"视频URL"` - CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间"` - UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间"` - DeletedAt *gtime.Time `orm:"deleted_at" json:"deletedAt" dc:"删除时间"` + Id int64 `orm:"id" json:"id" dc:"剧集ID"` + DramaId int64 `orm:"drama_id" json:"dramaId" dc:"短剧ID"` + Index int `orm:"idx" json:"index" dc:"剧集序号"` + Title string `orm:"title" json:"title" dc:"剧集标题"` + Description string `orm:"description" json:"description" dc:"剧情描述"` + Script string `orm:"script" json:"script" dc:"剧集脚本"` + Status string `orm:"status" json:"status" dc:"生成状态"` + VideoUrl string `orm:"video_url" json:"videoUrl" dc:"视频URL"` + CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间"` + DeletedAt *gtime.Time `orm:"deleted_at" json:"deletedAt" dc:"删除时间"` } diff --git a/shortdrama/model/segment_output.go b/shortdrama/model/segment_output.go index 7831d8d..438a760 100644 --- a/shortdrama/model/segment_output.go +++ b/shortdrama/model/segment_output.go @@ -25,3 +25,10 @@ type SegmentCharacter struct { Description string `json:"description"` ImageBase64 string `json:"-"` // 形象 base64(不序列化到 StepsData) } + +// VideoRef 视频模型 API 参考素材 +type VideoRef struct { + Type string `json:"type"` // "character" | "scene" | "prop" + Name string `json:"name"` // 实体名称 + MediaURL string `json:"mediaUrl"` // 参考素材 URL(base64 data URL 或 HTTP URL) +} diff --git a/shortdrama/service/drama_service.go b/shortdrama/service/drama_service.go index be8de3e..0608955 100644 --- a/shortdrama/service/drama_service.go +++ b/shortdrama/service/drama_service.go @@ -198,13 +198,19 @@ func (s *dramaService) GenerateEpisode(ctx context.Context, dramaId, epId int64, } // 默认使用并行生成模式 mode = "parallel" + // 预加载当前短剧的演员/场景/道具,构建引用索引 + genCtx2, err := BuildGenerationContext(context.Background(), d) + if err != nil { + return fmt.Errorf("构建生成上下文失败: %w", err) + } + g.Log().Infof(ctx, "预加载引用数据: %d个演员, %d个场景, %d个道具, %d个引用", + len(genCtx2.Characters), len(genCtx2.Scenes), len(genCtx2.Props), len(genCtx2.OrderedRefs)) genCtx := agent.WithModelConfig(context.Background(), &agent.ModelConfig{ APIKey: modelCfg.ChatApiKey, BaseURL: modelCfg.ChatBaseUrl, }) genCtx = agent.WithDramaId(genCtx, d.Id) - genCtx = agent.WithDramaId(genCtx, d.Id) segDurs := calcSegDurs(d.EpisodeDuration, modelCfg) numSegments := len(segDurs) @@ -257,7 +263,7 @@ func (s *dramaService) GenerateEpisode(ctx context.Context, dramaId, epId int64, for i, segDur := range segDurs { taskId := taskIds[i] g.Log().Infof(genCtx, "第%d集第%d段开始串行生成(segDur=%ds)", ep.Index, i+1, segDur) - if err := s.generateOneSegment(genCtx, d, ep, taskId, i, segDur, ""); err != nil { + if err := s.generateOneSegment(genCtx, d, ep, taskId, i, segDur, "", genCtx2); err != nil { g.Log().Errorf(genCtx, "第%d集第%d段串行生成失败: %v", ep.Index, i+1, err) _ = dao.GenerationTask.UpdateFailed(genCtx, taskId, err.Error()) clearPollCache(genCtx, epId) @@ -284,7 +290,7 @@ func (s *dramaService) GenerateEpisode(ctx context.Context, dramaId, epId int64, go func(idx, dur int, tid int64) { startTime := time.Now() g.Log().Infof(genCtx, "第%d集第%d段开始生成(segDur=%ds)", ep.Index, idx+1, dur) - if err := s.generateOneSegment(genCtx, d, ep, tid, idx, dur, ""); err != nil { + if err := s.generateOneSegment(genCtx, d, ep, tid, idx, dur, "", genCtx2); err != nil { g.Log().Errorf(genCtx, "第%d集第%d段生成失败(耗时%v): %v", ep.Index, idx+1, time.Since(startTime), err) _ = dao.GenerationTask.UpdateFailed(genCtx, tid, err.Error()) clearPollCache(genCtx, epId) @@ -303,13 +309,11 @@ func (s *dramaService) GenerateEpisode(ctx context.Context, dramaId, epId int64, } // generateOneSegment 生成一段内容:Agent → 保存演员/场景 → 保存脚本 → 提交视频 -func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama, ep *entity.Episode, taskId int64, segIdx, segDur int, feedback string) error { +func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama, ep *entity.Episode, taskId int64, segIdx, segDur int, feedback string, genCtx *GenerationContext) error { modelCfg := ConfigService.Get(ctx) - // 加载场景/道具/演员 - characters, _, _ := dao.Character.ListPageByDrama(ctx, d.Id, 1, -1) - scenes, _ := dao.Scene.ListByDrama(ctx, d.Id) - props, _ := dao.Prop.ListByDrama(ctx, d.Id) + // 使用预加载的场景/道具/演员 + characters := genCtx.Characters // 计算本段在整集中的起始时间(累计前几段时长) segDurs := calcSegDurs(d.EpisodeDuration, modelCfg) @@ -320,7 +324,7 @@ func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama, totalSegs := len(segDurs) // 调用 Agent - result, err := s.generateSegment(ctx, d, ep, segIdx, segDur, segStartTime, totalSegs, modelCfg, feedback, characters, scenes, props) + result, err := s.generateSegment(ctx, d, ep, segIdx, segDur, segStartTime, totalSegs, modelCfg, feedback, genCtx) if err != nil { return err } @@ -379,17 +383,6 @@ func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama, } } - // 保存脚本到 workspace - var scriptPath string - if segOutput.TextOutput != "" { - scriptName := fmt.Sprintf("第%d集第%d段(%ds).md", ep.Index, segIdx+1, segDur) - if path, err := s.saveTextFile(ctx, dramaTitle, segOutput.TextOutput, "脚本", scriptName); err == nil { - scriptPath = path - } else { - g.Log().Warningf(ctx, "保存脚本文件失败: %v", err) - } - } - // 读取 task 中已有的 NumSegments task, _ := dao.GenerationTask.GetOne(ctx, taskId) numSegments := 1 @@ -398,7 +391,16 @@ func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama, } // 提交视频合成 - taskID, submitErr := s.submitVideoTask(ctx, d, ep, segIdx, segDur, segOutput.Scenes) + // 构建引用列表:从Agent输出的角色名匹配预加载的参考图片 + var videoRefs []model.VideoRef + for _, ch := range segOutput.Characters { + if url := genCtx.LookupRef("演员", ch.Name); url != "" { + videoRefs = append(videoRefs, model.VideoRef{ + Type: "character", Name: ch.Name, MediaURL: url, + }) + } + } + taskID, submitErr := s.submitVideoTask(ctx, d, ep, segIdx, segDur, segOutput.Scenes, videoRefs) if submitErr != nil { g.Log().Warningf(ctx, "第%d段视频提交失败,后台轮询器将自动重试: %v", segIdx+1, submitErr) } else { @@ -410,9 +412,6 @@ func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama, "num_segments": numSegments, "updated_at": nil, } - if scriptPath != "" { - updateFields["script_path"] = scriptPath - } if taskID != "" { updateFields["video_task_id"] = taskID } @@ -424,7 +423,7 @@ func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama, // generateSegment 调用 Agent 生成一段内容 func (s *dramaService) generateSegment(ctx context.Context, d *entity.Drama, ep *entity.Episode, segIdx, segDur, segStartTime, totalSegs int, modelCfg *entity.ModelConfig, feedback string, - characters []*entity.Character, scenes []*entity.Scene, props []*entity.Prop) (string, error) { + genCtx *GenerationContext) (string, error) { chatModel, err := agent.NewChatModel(ctx, &agent.ModelConfig{ ModelName: modelCfg.ChatModelName, @@ -438,7 +437,7 @@ func (s *dramaService) generateSegment(ctx context.Context, d *entity.Drama, ep } systemPrompt := s.buildSegPrompt(ctx, d, ep, segIdx, segDur, totalSegs, modelCfg) - userInput := s.buildSegUserInput(d, ep, segIdx, segDur, segStartTime, totalSegs, feedback, characters, scenes, props, modelCfg) + userInput := s.buildSegUserInput(d, ep, segIdx, segDur, segStartTime, totalSegs, feedback, genCtx, modelCfg) // 估算输入 token 数(中文约 1.5 字符/token),动态调整 max_tokens inputChars := len([]rune(systemPrompt)) + len([]rune(userInput)) @@ -522,7 +521,7 @@ func (s *dramaService) buildSegPrompt(ctx context.Context, d *entity.Drama, ep * ) } -func (s *dramaService) buildSegUserInput(d *entity.Drama, ep *entity.Episode, segIdx, segDur, segStartTime, totalSegs int, feedback string, characters []*entity.Character, scenes []*entity.Scene, props []*entity.Prop, modelCfg *entity.ModelConfig) string { +func (s *dramaService) buildSegUserInput(d *entity.Drama, ep *entity.Episode, segIdx, segDur, segStartTime, totalSegs int, feedback string, genCtx *GenerationContext, modelCfg *entity.ModelConfig) string { feedbackText := "" if feedback != "" { feedbackText = fmt.Sprintf("\n【用户反馈】\n%s\n请根据以上反馈调整本段内容。", feedback) @@ -530,18 +529,23 @@ func (s *dramaService) buildSegUserInput(d *entity.Drama, ep *entity.Episode, se // 演员信息 charText := "" - if len(characters) > 0 { + if len(genCtx.Characters) > 0 { var charLines []string - for _, c := range characters { + for _, c := range genCtx.Characters { voiceDesc := c.VoicePath if voiceDesc == "" { voiceDesc = "有配音(未指定文件)" } + refIdx := genCtx.FindRefIndex("演员", c.Name) + refIdxStr := "" + if refIdx >= 0 { + refIdxStr = fmt.Sprintf(",参考图索引:%d", refIdx) + } portraitInfo := "" if c.PortraitPath != "" { portraitInfo = fmt.Sprintf(",形象参考:%s", c.PortraitPath) } - info := fmt.Sprintf("- 名称:%s,描述:%s,声音:%s%s", c.Name, c.Description, voiceDesc, portraitInfo) + info := fmt.Sprintf("- 名称:%s,描述:%s,声音:%s%s%s", c.Name, c.Description, voiceDesc, portraitInfo, refIdxStr) charLines = append(charLines, info) } charText = "\n【演员信息】\n" + strings.Join(charLines, "\n") @@ -551,14 +555,19 @@ func (s *dramaService) buildSegUserInput(d *entity.Drama, ep *entity.Episode, se // 场景信息 sceneText := "" - if len(scenes) > 0 { + if len(genCtx.Scenes) > 0 { var sceneLines []string - for _, sc := range scenes { + for _, sc := range genCtx.Scenes { + refIdx := genCtx.FindRefIndex("场景", sc.Name) + refIdxStr := "" + if refIdx >= 0 { + refIdxStr = fmt.Sprintf(",参考图索引:%d", refIdx) + } imgInfo := "" if sc.ImagePath != "" { imgInfo = fmt.Sprintf(",图片路径:%s", sc.ImagePath) } - sceneLines = append(sceneLines, fmt.Sprintf("- 名称:%s,描述:%s%s", sc.Name, sc.Description, imgInfo)) + sceneLines = append(sceneLines, fmt.Sprintf("- 名称:%s,描述:%s%s%s", sc.Name, sc.Description, imgInfo, refIdxStr)) } sceneText = "\n【可用场景】\n" + strings.Join(sceneLines, "\n") } else { @@ -567,14 +576,19 @@ func (s *dramaService) buildSegUserInput(d *entity.Drama, ep *entity.Episode, se // 道具信息 propText := "" - if len(props) > 0 { + if len(genCtx.Props) > 0 { var propLines []string - for _, p := range props { + for _, p := range genCtx.Props { + refIdx := genCtx.FindRefIndex("道具", p.Name) + refIdxStr := "" + if refIdx >= 0 { + refIdxStr = fmt.Sprintf(",参考图索引:%d", refIdx) + } imgInfo := "" if p.ImagePath != "" { imgInfo = fmt.Sprintf(",图片路径:%s", p.ImagePath) } - propLines = append(propLines, fmt.Sprintf("- 名称:%s,描述:%s%s", p.Name, p.Description, imgInfo)) + propLines = append(propLines, fmt.Sprintf("- 名称:%s,描述:%s%s%s", p.Name, p.Description, imgInfo, refIdxStr)) } propText = "\n【可用道具】\n" + strings.Join(propLines, "\n") } else { @@ -685,6 +699,13 @@ func (s *dramaService) FeedbackSegment(ctx context.Context, taskId int64, feedba segDurs := calcSegDurs(d.EpisodeDuration, modelCfg) segDur := segDurs[task.SegmentIdx] + // 预加载引用数据 + feedbackGenCtx, fbErr := BuildGenerationContext(context.Background(), d) + if fbErr != nil { + g.Log().Errorf(ctx, "反馈重试构建生成上下文失败: %v", fbErr) + return fbErr + } + go func() { genCtx := context.Background() genCtx = agent.WithModelConfig(genCtx, &agent.ModelConfig{ @@ -693,7 +714,7 @@ func (s *dramaService) FeedbackSegment(ctx context.Context, taskId int64, feedba }) genCtx = agent.WithDramaId(genCtx, d.Id) g.Log().Infof(genCtx, "第%d集第%d段根据反馈重新生成", ep.Index, task.SegmentIdx+1) - if err := s.generateOneSegment(genCtx, d, ep, taskId, task.SegmentIdx, segDur, feedback); err != nil { + if err := s.generateOneSegment(genCtx, d, ep, taskId, task.SegmentIdx, segDur, feedback, feedbackGenCtx); err != nil { g.Log().Errorf(genCtx, "第%d集第%d段重新生成失败: %v", ep.Index, task.SegmentIdx+1, err) _ = dao.GenerationTask.UpdateFailed(genCtx, taskId, err.Error()) clearPollCache(genCtx, task.EpisodeId) @@ -876,35 +897,6 @@ func (s *dramaService) pollPendingVideos(ctx context.Context) { continue } - // 没有 VideoTaskId — 尝试重新提交 - if task.ScriptPath == "" { - continue - } - d, _ := dao.Drama.GetOne(ctx, task.DramaId) - if d == nil { - continue - } - ep, _ := dao.Episode.GetOne(ctx, task.EpisodeId) - if ep == nil { - continue - } - - // 从脚本文件中读取 Agent 输出,解析场景用于视频重试 - textBytes, readErr := os.ReadFile(task.ScriptPath) - if readErr != nil { - g.Log().Warningf(ctx, "轮询器: 任务 %d 读取脚本文件失败: %v", task.Id, readErr) - continue - } - segOutput := model.ParseAgentOutput(string(textBytes), task.SegmentIdx) - // 轮询器重试时不携带 duration(初次提交已在 generateOneSegment 中携带,避免 async 任务再次因 duration 不支持而失败) - taskID, submitErr := s.submitVideoTask(ctx, d, ep, task.SegmentIdx, 0, segOutput.Scenes) - if submitErr != nil { - g.Log().Warningf(ctx, "轮询器: 任务 %d 第%d段视频重试提交失败: %v", task.Id, task.SegmentIdx+1, submitErr) - continue - } - _ = dao.GenerationTask.UpdateFields(ctx, task.Id, g.Map{"video_task_id": taskID}) - g.Log().Infof(ctx, "轮询器: 任务 %d 第%d段视频重试提交成功: %s", task.Id, task.SegmentIdx+1, taskID) - epUpdates[task.EpisodeId] = true } // 刷新所有 generating 任务的 episode 缓存(不管状态有无变化,确保前端轮询不走 DB) @@ -984,7 +976,7 @@ func (s *dramaService) pollVideoTaskOnce(ctx context.Context, modelCfg *entity.M // ==================== Video Generation ==================== // submitVideoTask 提交视频合成任务(duration 使用指定的 segDur,不再硬编码) -func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep *entity.Episode, segIdx, segDur int, scenes []model.SegmentScene) (string, error) { +func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep *entity.Episode, segIdx, segDur int, scenes []model.SegmentScene, refs []model.VideoRef) (string, error) { modelCfg := ConfigService.Get(ctx) if modelCfg.VideoApiKey == "" || modelCfg.VideoBaseUrl == "" || modelCfg.VideoModelName == "" { return "", fmt.Errorf("视频模型未配置") @@ -1008,7 +1000,23 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep // 视频模型输入文本有长度限制,根据模型配置中的 max_tokens 决定截断长度 sceneText := strings.Join(sceneDescs, ";") - prompt := fmt.Sprintf("短剧《%s》第%d集第%d段:%s", d.Title, ep.Index, segIdx+1, sceneText) + + // 构建参考素材说明 + var refImages []string + refPrompt := "" + if len(refs) > 0 { + refImages = make([]string, 0, len(refs)) + var refParts []string + for _, r := range refs { + refParts = append(refParts, fmt.Sprintf("%s(%s)", r.Name, r.Type)) + if r.MediaURL != "" { + refImages = append(refImages, r.MediaURL) + } + } + refPrompt = fmt.Sprintf(",参考素材:%s", strings.Join(refParts, "、")) + } + + prompt := fmt.Sprintf("短剧《%s》第%d集第%d段:%s%s", d.Title, ep.Index, segIdx+1, sceneText, refPrompt) maxInputLen := 3000 if modelCfg.MaxTokens > 0 { maxInputLen = modelCfg.MaxTokens @@ -1030,9 +1038,15 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep body := map[string]any{ "model": modelCfg.VideoModelName, - "input": map[string]any{ - "prompt": prompt, - }, + "input": func() map[string]any { + m := map[string]any{ + "prompt": prompt, + } + if len(refImages) > 0 { + m["images"] = refImages + } + return m + }(), "parameters": map[string]any{ "size": "720*1280", }, @@ -1350,21 +1364,10 @@ func (s *dramaService) copyFile(src, dst string) error { // ==================== Helpers ==================== -// cleanupEpisodeWorkspace 清理本集之前生成的脚本和视频文件 +// cleanupEpisodeWorkspace 清理本集之前生成的视频文件 func cleanupEpisodeWorkspace(ctx context.Context, dramaTitle string, epIndex int, epTitle string) { wsDir := WorkspaceDir(dramaTitle) - // 清理脚本文件:第{epIndex}集*.md - scriptsDir := filepath.Join(wsDir, "脚本") - if entries, err := os.ReadDir(scriptsDir); err == nil { - prefix := fmt.Sprintf("第%d集", epIndex) - for _, e := range entries { - if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ".md") { - _ = os.Remove(filepath.Join(scriptsDir, e.Name())) - } - } - } - // 清理产出视频文件 videoDir := filepath.Join(wsDir, "产出视频") if entries, err := os.ReadDir(videoDir); err == nil { @@ -1457,20 +1460,6 @@ func (s *dramaService) saveBase64Image(ctx context.Context, dramaTitle, b64Data, return dest, nil } -// saveTextFile 将文本内容保存到 workspace 目录,返回绝对路径 -func (s *dramaService) saveTextFile(ctx context.Context, dramaTitle, content, subDir, fileName string) (string, error) { - wsDir := WorkspaceDir(dramaTitle) - subPath := filepath.Join(wsDir, subDir) - if err := os.MkdirAll(subPath, 0755); err != nil { - return "", fmt.Errorf("创建目录失败: %w", err) - } - dest := filepath.Join(subPath, fileName) - if err := os.WriteFile(dest, []byte(content), 0644); err != nil { - return "", fmt.Errorf("写入文件失败: %w", err) - } - return dest, nil -} - // downloadToLocal 下载远程文件到本地 func (s *dramaService) downloadToLocal(ctx context.Context, url, dramaTitle, subDir, fileName string) (string, error) { wsDir := WorkspaceDir(dramaTitle) diff --git a/shortdrama/service/episode_service.go b/shortdrama/service/episode_service.go index dfc3e24..56d7327 100644 --- a/shortdrama/service/episode_service.go +++ b/shortdrama/service/episode_service.go @@ -7,17 +7,19 @@ import ( "path/filepath" "strings" + "video-factory/shortdrama/agent" "video-factory/shortdrama/consts/public" "video-factory/shortdrama/dao" "video-factory/shortdrama/model/entity" + "github.com/cloudwego/eino/schema" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) // ==================== Episode CRUD ==================== -func (s *dramaService) AddEpisode(ctx context.Context, dramaId int64, title, script string, index int) (int64, error) { +func (s *dramaService) AddEpisode(ctx context.Context, dramaId int64, title, description, script string, index int) (int64, error) { if index <= 0 { maxIdx, err := dao.Episode.GetMaxIndex(ctx, dramaId) if err != nil { @@ -26,28 +28,20 @@ func (s *dramaService) AddEpisode(ctx context.Context, dramaId int64, title, scr index = maxIdx + 1 } id, err := dao.Episode.Insert(ctx, &entity.Episode{ - DramaId: dramaId, - Index: index, - Title: title, - Script: script, + DramaId: dramaId, + Index: index, + Title: title, + Description: description, + Script: script, + Status: "pending", }) if err != nil { return 0, err } - // 保存剧集描述到 workspace 文件 - if script != "" { - if d, _ := dao.Drama.GetOne(ctx, dramaId); d != nil { - safeName := sanitizeDirName(title) - fileName := fmt.Sprintf("%s.md", safeName) - if _, err := s.saveTextFile(ctx, d.Title, script, "剧集描述", fileName); err != nil { - g.Log().Warningf(ctx, "保存剧集描述文件失败: %v", err) - } - } - } return id, nil } -func (s *dramaService) UpdateEpisode(ctx context.Context, dramaId, epId int64, title, script string, index int) error { +func (s *dramaService) UpdateEpisode(ctx context.Context, dramaId, epId int64, title, description, script string, index int) error { e, err := dao.Episode.GetOne(ctx, epId) if err != nil { return err @@ -58,8 +52,14 @@ func (s *dramaService) UpdateEpisode(ctx context.Context, dramaId, epId int64, t if title != "" { e.Title = title } + if description != "" { + e.Description = description + } if script != "" { e.Script = script + if e.Status == "" { + e.Status = "pending" + } } if index > 0 { e.Index = index @@ -67,16 +67,6 @@ func (s *dramaService) UpdateEpisode(ctx context.Context, dramaId, epId int64, t if err := dao.Episode.Update(ctx, epId, e); err != nil { return err } - // 保存剧集描述到 workspace 文件 - if script != "" { - if d, _ := dao.Drama.GetOne(ctx, dramaId); d != nil { - safeName := sanitizeDirName(e.Title) - fileName := fmt.Sprintf("%s.md", safeName) - if _, err := s.saveTextFile(ctx, d.Title, script, "剧集描述", fileName); err != nil { - g.Log().Warningf(ctx, "保存剧集描述文件失败: %v", err) - } - } - } return nil } @@ -86,15 +76,6 @@ func (s *dramaService) DeleteEpisode(ctx context.Context, dramaId, epId int64) e return err } - // 先收集脚本路径和剧集标题(在删除前读取,用于后续文件清理) - tasks, _ := dao.GenerationTask.ListByEpisodeIds(ctx, []int64{epId}) - scriptPaths := make([]string, 0, len(tasks)) - for _, t := range tasks { - if t.ScriptPath != "" { - scriptPaths = append(scriptPaths, t.ScriptPath) - } - } - var eTitle string if d != nil { if e, _ := dao.Episode.GetOne(ctx, epId); e != nil { @@ -119,8 +100,6 @@ func (s *dramaService) DeleteEpisode(ctx context.Context, dramaId, epId int64) e workspaceDir := WorkspaceDir(d.Title) safeEp := sanitizeDirName(eTitle) - os.Remove(filepath.Join(workspaceDir, "剧集描述", safeEp+".md")) - // 清理产出视频目录中本集的所有文件 videoDir := filepath.Join(workspaceDir, "产出视频") if entries, err := os.ReadDir(videoDir); err == nil { @@ -135,9 +114,105 @@ func (s *dramaService) DeleteEpisode(ctx context.Context, dramaId, epId int64) e } } - for _, p := range scriptPaths { - os.Remove(p) - } } return nil } + +// ==================== Script Generation ==================== + +// GenerateScript 使用AI为指定短剧的剧集生成脚本 +func (s *dramaService) GenerateScript(ctx context.Context, dramaId int64, episodeTitle, description string) (string, error) { + d, err := dao.Drama.GetOne(ctx, dramaId) + if err != nil { + return "", err + } + if d == nil { + return "", fmt.Errorf("短剧不存在") + } + + modelCfg := ConfigService.Get(ctx) + if modelCfg.ChatApiKey == "" || modelCfg.ChatModelName == "" { + return "", fmt.Errorf("模型未配置") + } + + // 加载演员/场景/道具上下文 + genCtx, err := BuildGenerationContext(ctx, d) + if err != nil { + return "", fmt.Errorf("加载短剧上下文失败: %w", err) + } + + // 创建聊天模型 + chatModel, err := agent.NewChatModel(ctx, &agent.ModelConfig{ + ModelName: modelCfg.ChatModelName, + APIKey: modelCfg.ChatApiKey, + BaseURL: modelCfg.ChatBaseUrl, + MaxTokens: modelCfg.MaxTokens, + Temperature: float32(modelCfg.Temperature), + }) + if err != nil { + return "", err + } + + systemPrompt := "你是一位专业的短剧编剧。请根据短剧信息和可用演员、场景、道具,创作一份详细的单集剧本。" + userInput := s.buildScriptGenUserInput(d, episodeTitle, description, genCtx) + + messages := []*schema.Message{ + schema.SystemMessage(systemPrompt), + schema.UserMessage(userInput), + } + result, err := chatModel.Generate(ctx, messages) + if err != nil { + return "", fmt.Errorf("生成脚本失败: %w", err) + } + + script := result.Content + if script == "" { + return "", fmt.Errorf("生成的脚本为空") + } + + g.Log().Infof(ctx, "脚本生成成功: 短剧=%s, 剧集=%s, 长度=%d字符", d.Title, episodeTitle, len([]rune(script))) + return script, nil +} + +// buildScriptGenUserInput 构建脚本生成的用户输入提示 +func (s *dramaService) buildScriptGenUserInput(d *entity.Drama, episodeTitle, description string, genCtx *GenerationContext) string { + var b strings.Builder + + fmt.Fprintf(&b, "【短剧信息】\n标题:%s\n风格:%s\n每集时长:%d秒\n\n", d.Title, d.Style, d.EpisodeDuration) + + if episodeTitle != "" { + fmt.Fprintf(&b, "【目标剧集】\n%s\n\n", episodeTitle) + } + + if description != "" { + fmt.Fprintf(&b, "【剧情描述】\n%s\n\n", description) + } + + if len(genCtx.Characters) > 0 { + b.WriteString("【可用演员】\n") + for _, c := range genCtx.Characters { + fmt.Fprintf(&b, "- %s:%s\n", c.Name, c.Description) + } + b.WriteString("\n") + } + + if len(genCtx.Scenes) > 0 { + b.WriteString("【可用场景】\n") + for _, sc := range genCtx.Scenes { + fmt.Fprintf(&b, "- %s:%s\n", sc.Name, sc.Description) + } + b.WriteString("\n") + } + + if len(genCtx.Props) > 0 { + b.WriteString("【可用道具】\n") + for _, p := range genCtx.Props { + fmt.Fprintf(&b, "- %s\n", p.Name) + } + b.WriteString("\n") + } + + b.WriteString(fmt.Sprintf("【要求】\n请为上述剧集创作一份详细的剧本。剧本必须按以下格式:\n\n本集标题:[标题]\n\n【场景1:[场景名称]】\n[场景详细描述,包括环境、时间、氛围等]\n\n[演员名]:[台词]\n[演员名]:[台词]\n\n【场景2:[场景名称]】\n...\n\n要求:\n1. 剧本包含具体的场景描述和人物对话\n2. 所有场景时长之和应等于 %d 秒\n3. 优先使用提供的演员、场景和道具,如需新增请合理创作\n4. 对话自然流畅,情节有起伏\n5. 场景数量建议3-8个\n", d.EpisodeDuration)) + + return b.String() +} diff --git a/shortdrama/service/generation_context.go b/shortdrama/service/generation_context.go new file mode 100644 index 0000000..8c09ba3 --- /dev/null +++ b/shortdrama/service/generation_context.go @@ -0,0 +1,175 @@ +package service + +import ( + "context" + "fmt" + "sort" + + "video-factory/shortdrama/dao" + "video-factory/shortdrama/model/entity" +) + +// RefIndex 引用索引:{"演员":{"张三":"data:image/...;base64,..."}, "场景":{"..."}, "道具":{"..."}} +type RefIndex map[string]map[string]string + +// OrderedRef 带索引的引用,用于提示词中描述"张三使用参考图第N张" +type OrderedRef struct { + Category string // "演员" | "场景" | "道具" + Name string + URL string // base64 data URL + Index int // 在 OrderedRefs 中的位置 +} + +// GenerationContext 一次生成会话的上下文,预加载当前短剧的演员/场景/道具数据 +type GenerationContext struct { + Drama *entity.Drama + Characters []*entity.Character + Scenes []*entity.Scene + Props []*entity.Prop + + // RefIndex 按名称索引,用于 Agent 输出匹配 + RefIndex RefIndex + + // OrderedRefs 拍平的有序列表,Index 供提示词引用 + OrderedRefs []*OrderedRef +} + +// BuildGenerationContext 构建一次生成会话的上下文 +func BuildGenerationContext(ctx context.Context, drama *entity.Drama) (*GenerationContext, error) { + characters, _, err := dao.Character.ListPageByDrama(ctx, drama.Id, 1, -1) + if err != nil { + return nil, fmt.Errorf("加载演员失败: %w", err) + } + + scenes, err := dao.Scene.ListByDrama(ctx, drama.Id) + if err != nil { + return nil, fmt.Errorf("加载场景失败: %w", err) + } + + props, err := dao.Prop.ListByDrama(ctx, drama.Id) + if err != nil { + return nil, fmt.Errorf("加载道具失败: %w", err) + } + + ctx2 := &GenerationContext{ + Drama: drama, + Characters: characters, + Scenes: scenes, + Props: props, + RefIndex: make(RefIndex), + } + + // 构建 RefIndex + if err := ctx2.buildRefIndex(); err != nil { + return nil, err + } + + return ctx2, nil +} + +// buildRefIndex 根据预加载的实体数据构建 RefIndex 和 OrderedRefs +func (c *GenerationContext) buildRefIndex() error { + c.RefIndex = make(RefIndex) + c.OrderedRefs = nil + + // 演员索引 + charIdx := make(map[string]string, len(c.Characters)) + for _, ch := range c.Characters { + 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 + } + if len(charIdx) > 0 { + c.RefIndex["演员"] = charIdx + } + + // 场景索引 + sceneIdx := make(map[string]string, len(c.Scenes)) + for _, sc := range c.Scenes { + 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 + } + if len(sceneIdx) > 0 { + c.RefIndex["场景"] = sceneIdx + } + + // 道具索引 + propIdx := make(map[string]string, len(c.Props)) + for _, p := range c.Props { + 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 + } + if len(propIdx) > 0 { + c.RefIndex["道具"] = propIdx + } + + // 构建有序索引(拍平成 OrderedRefs,按 演员→场景→道具 顺序) + idx := 0 + for _, category := range []string{"演员", "场景", "道具"} { + m, ok := c.RefIndex[category] + if !ok { + continue + } + // 将 map 转为有序列表(保证每次顺序一致) + names := make([]string, 0, len(m)) + for name := range m { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + c.OrderedRefs = append(c.OrderedRefs, &OrderedRef{ + Category: category, + Name: name, + URL: m[name], + Index: idx, + }) + idx++ + } + } + + return nil +} + +// LookupRef 按实体类型和名称查找引用 URL,返回空字符串表示未找到 +func (c *GenerationContext) LookupRef(category, name string) string { + if m, ok := c.RefIndex[category]; ok { + return m[name] + } + return "" +} + +// FindRefIndex 按实体类型和名称查找其在 OrderedRefs 中的索引位置,返回 -1 表示未找到 +func (c *GenerationContext) FindRefIndex(category, name string) int { + for _, ref := range c.OrderedRefs { + if ref.Category == category && ref.Name == name { + return ref.Index + } + } + return -1 +} diff --git a/shortdrama/service/workspace.go b/shortdrama/service/workspace.go index 1b81038..814a6de 100644 --- a/shortdrama/service/workspace.go +++ b/shortdrama/service/workspace.go @@ -13,7 +13,7 @@ import ( const workspaceRoot = "workspace" -var workspaceSubdirs = []string{"产出视频", "演员形象", "演员声音", "场景", "道具", "剧集描述", "脚本"} +var workspaceSubdirs = []string{"产出视频", "演员形象", "演员声音", "场景", "道具"} // sanitizeDirName 将短剧标题转为安全的目录名 func sanitizeDirName(title string) string {