This commit is contained in:
2026-07-07 12:44:21 +08:00
parent 7c55aca007
commit cbe868147f
9 changed files with 434 additions and 161 deletions
+10 -2
View File
@@ -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 {
+5
View File
@@ -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) {
+24 -11
View File
@@ -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:"生成的脚本"`
}
+11 -10
View File
@@ -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:"删除时间"`
}
+7
View File
@@ -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"` // 参考素材 URLbase64 data URL 或 HTTP URL
}
+86 -97
View File
@@ -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)
+115 -40
View File
@@ -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()
}
+175
View File
@@ -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
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
const workspaceRoot = "workspace"
var workspaceSubdirs = []string{"产出视频", "演员形象", "演员声音", "场景", "道具", "剧集描述", "脚本"}
var workspaceSubdirs = []string{"产出视频", "演员形象", "演员声音", "场景", "道具"}
// sanitizeDirName 将短剧标题转为安全的目录名
func sanitizeDirName(title string) string {