From 26c671aa9a8e1830b2844cdc780f7f1fcf5ebdbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Fri, 24 Jul 2026 09:28:34 +0800 Subject: [PATCH] 1 --- common/http/http.go | 1 + short_drama.db | Bin 462848 -> 462848 bytes shortdrama/controller/agent_controller.go | 100 +- shortdrama/controller/customer_controller.go | 62 +- .../controller/transaction_controller.go | 3 +- shortdrama/service/agent_service.go | 101 ++ shortdrama/service/customer_service.go | 68 + shortdrama/service/generation_service.go | 1599 ++++++++--------- 8 files changed, 972 insertions(+), 962 deletions(-) diff --git a/common/http/http.go b/common/http/http.go index d6df2e3..8667da4 100644 --- a/common/http/http.go +++ b/common/http/http.go @@ -21,6 +21,7 @@ func init() { panic("设置时区失败") } Httpserver.SetOpenApiPath("/api.json") + // 全局 panic 恢复(最先注册,作为最外层包裹) Httpserver.BindMiddlewareDefault(ghttp.MiddlewareHandlerResponse) // CORS - allow all origins Httpserver.BindMiddlewareDefault(func(r *ghttp.Request) { diff --git a/short_drama.db b/short_drama.db index fb473c66cbf24d7317f67148f34dbe924ea2282d..f69f6538d61201956ed38ef69a0bd8bd40c888b6 100644 GIT binary patch delta 56 zcmZozAk(lwW`eX}2?GN|ArSimu>%7GyWm6}W5$xkgw_Pc)&!>31m@NRmevH;)&#b# I3G55_0ehIArSimu>%7GJNHB#W5&G3gw_Pc)&!>31m@NRmevH;)&#b# I3G55_0epN8GXMYp diff --git a/shortdrama/controller/agent_controller.go b/shortdrama/controller/agent_controller.go index 709422c..467fe04 100644 --- a/shortdrama/controller/agent_controller.go +++ b/shortdrama/controller/agent_controller.go @@ -3,13 +3,9 @@ package controller import ( "context" - "video-factory/shortdrama/dao" "video-factory/shortdrama/model/dto" "video-factory/shortdrama/model/entity" "video-factory/shortdrama/service" - - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gtime" ) type agent struct{} @@ -20,6 +16,11 @@ type createAgentRes struct { Id int64 `json:"id"` } +type getAgentDetailRes struct { + User *entity.User `json:"user"` + Profile *entity.AgentProfile `json:"profile"` +} + func (c *agent) Create(ctx context.Context, req *dto.CreateAgentReq) (res *createAgentRes, err error) { user, err := service.AgentService.CreateAgent(ctx, req.Username, req.Password, req.Phone, req.Name, req.Province, req.Region, req.RegionProtected) if err != nil { @@ -29,63 +30,11 @@ func (c *agent) Create(ctx context.Context, req *dto.CreateAgentReq) (res *creat } func (c *agent) List(ctx context.Context, req *dto.ListAgentReq) (res *dto.ListAgentRes, err error) { - rows, total, err := dao.AgentProfile.ListAgentWithProfile(ctx, req.Keyword, req.Phone, req.Province, req.Region, req.ExpiredAtFrom, req.ExpiredAtTo, req.Page, req.PageSize) - if err != nil { - return nil, err - } - list := make([]*dto.ListAgentItem, len(rows)) - for i, r := range rows { - list[i] = &dto.ListAgentItem{ - User: &entity.User{ - Id: r.Id, - Username: r.Username, - Phone: r.Phone, - Name: r.Name, - Province: r.Province, - Region: r.Region, - ExpiredAt: r.ExpiredAt, - }, - RegionProtected: r.RegionProtected, - MaxCustomers: r.MaxCustomers, - } - } - return &dto.ListAgentRes{List: list, Total: total, Page: req.Page, PageSize: req.PageSize}, nil + return service.AgentService.ListAgents(ctx, req) } func (c *agent) Update(ctx context.Context, req *dto.UpdateAgentReq) (res *struct{}, err error) { - if req.Name != "" { - m := g.Map{ - "name": req.Name, - "region": req.Region, - } - if req.Phone != "" { - m["phone"] = req.Phone - } - if req.Province != "" { - m["province"] = req.Province - } - if err := dao.User.UpdateFields(ctx, req.Id, m); err != nil { - return nil, err - } - } - - // 确保 agent_profile 存在 - profile, _ := dao.AgentProfile.Get(ctx, req.Id) - if profile == nil { - maxCustomers := 0 - if pricing, _ := dao.RegionPricing.GetByRegion(ctx, req.Region, 0); pricing != nil { - maxCustomers = pricing.MaxCustomers - } - _ = dao.AgentProfile.Save(ctx, &entity.AgentProfile{ - UserId: req.Id, - MaxCustomers: maxCustomers, - Renewals: 0, - ExpiredAt: gtime.Now().AddDate(1, 0, 0), - RegionProtected: false, - }) - } - - return nil, nil + return nil, service.AgentService.UpdateAgent(ctx, req) } func (c *agent) Renew(ctx context.Context, req *dto.RenewAgentReq) (res *struct{}, err error) { @@ -107,45 +56,14 @@ func (c *agent) CreateRenewOrder(ctx context.Context, req *dto.CreateRenewOrderR }, nil } -type getAgentDetailRes struct { - User *entity.User `json:"user"` - Profile *entity.AgentProfile `json:"profile"` -} - func (c *agent) ListRenewals(ctx context.Context, req *dto.ListRenewalsReq) (res *dto.ListRenewalsRes, err error) { - orders, err := dao.PaymentOrder.ListByUserAndType(ctx, req.AgentId, "renewal") - if err != nil { - return nil, err - } - list := make([]*dto.RenewalRecord, len(orders)) - for i, o := range orders { - paidAt := "" - if o.PaidAt != nil { - paidAt = o.PaidAt.Format("Y-m-d H:i:s") - } - createdAt := "" - if o.CreatedAt != nil { - createdAt = o.CreatedAt.Format("Y-m-d H:i:s") - } - list[i] = &dto.RenewalRecord{ - Id: o.Id, - OrderNo: o.OrderNo, - Amount: o.Amount, - Channel: o.Channel, - Status: o.Status, - Subject: o.Subject, - PaidAt: paidAt, - CreatedAt: createdAt, - } - } - return &dto.ListRenewalsRes{List: list}, nil + return service.AgentService.ListAgentRenewals(ctx, req.AgentId) } func (c *agent) Get(ctx context.Context, req *struct{ Id int64 }) (res *getAgentDetailRes, err error) { - user, err := dao.User.GetOne(ctx, req.Id) + user, profile, err := service.AgentService.GetAgentDetail(ctx, req.Id) if err != nil || user == nil { return nil, err } - profile, _ := dao.AgentProfile.Get(ctx, req.Id) return &getAgentDetailRes{User: user, Profile: profile}, nil } diff --git a/shortdrama/controller/customer_controller.go b/shortdrama/controller/customer_controller.go index cb7c5e1..bb1cf47 100644 --- a/shortdrama/controller/customer_controller.go +++ b/shortdrama/controller/customer_controller.go @@ -2,15 +2,12 @@ package controller import ( "context" - "errors" - "video-factory/shortdrama/dao" "video-factory/shortdrama/middleware" "video-factory/shortdrama/model/dto" "video-factory/shortdrama/service" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gtime" ) type customer struct{} @@ -35,68 +32,15 @@ func (c *customer) Create(ctx context.Context, req *dto.CreateCustomerReq) (res } func (c *customer) List(ctx context.Context, req *dto.ListCustomerReq) (res *dto.ListCustomerRes, err error) { - rows, total, err := dao.CustomerProfile.ListCustomerWithAgent(ctx, req.AgentId, req.Keyword, req.Phone, req.Province, req.Region, req.AgentName, req.Page, req.PageSize) - if err != nil { - return nil, err - } - items := make([]*dto.ListCustomerItem, len(rows)) - for i, r := range rows { - createdAt := "" - if r.CreatedAt != nil { - createdAt = r.CreatedAt.Format("Y-m-d H:i:s") - } - items[i] = &dto.ListCustomerItem{ - Id: r.Id, - Phone: r.Phone, - Name: r.Name, - Province: r.Province, - Region: r.Region, - Address: r.Address, - AgentId: r.AgentId, - AgentName: r.AgentName, - Balance: r.Balance, - CreatedAt: createdAt, - } - } - return &dto.ListCustomerRes{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil + return service.CustomerService.ListCustomers(ctx, req) } func (c *customer) Detail(ctx context.Context, req *struct{ Id int64 }) (res *dto.CustomerDetail, err error) { - user, err := dao.User.GetOne(ctx, req.Id) - if err != nil || user == nil { - return nil, err - } - profile, _ := dao.CustomerProfile.Get(ctx, req.Id) - balance := service.CustomerService.GetBalance(ctx, req.Id) - return &dto.CustomerDetail{User: user, Profile: profile, Balance: balance}, nil + return service.CustomerService.GetCustomerDetail(ctx, req.Id) } func (c *customer) Update(ctx context.Context, req *dto.UpdateCustomerReq) (res *struct{}, err error) { r := g.RequestFromCtx(ctx) agentId := middleware.GetUserId(r) - if ap, _ := dao.AgentProfile.Get(ctx, agentId); ap != nil && ap.ExpiredAt != nil && ap.ExpiredAt.Before(gtime.Now()) { - return nil, errors.New("代理商已过期,无法编辑客户") - } - - m := g.Map{} - if req.Phone != "" { - m["phone"] = req.Phone - } - if req.Name != "" { - m["name"] = req.Name - } - if req.Address != "" { - m["address"] = req.Address - // re-extract province and region from the updated address - province, region, err := service.AgentService.ExtractRegionFromAddress(ctx, req.Address) - if err != nil { - return nil, err - } - m["province"] = province - m["region"] = region - } - if len(m) == 0 { - return nil, nil - } - return nil, dao.User.UpdateFields(ctx, req.Id, m) + return nil, service.CustomerService.UpdateCustomer(ctx, agentId, req) } diff --git a/shortdrama/controller/transaction_controller.go b/shortdrama/controller/transaction_controller.go index 5fc3aac..cf0dffb 100644 --- a/shortdrama/controller/transaction_controller.go +++ b/shortdrama/controller/transaction_controller.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "video-factory/shortdrama/dao" "video-factory/shortdrama/middleware" "video-factory/shortdrama/model/dto" "video-factory/shortdrama/service" @@ -22,7 +21,7 @@ func (c *transaction) List(ctx context.Context, req *dto.ListTransactionReq) (re if userId == 0 { userId = middleware.GetUserId(r) } - list, total, err := dao.AccountTransaction.ListByUser(ctx, userId, req.Type, req.Page, req.PageSize) + list, total, err := service.TransactionService.ListByUser(ctx, userId, req.Type, req.Page, req.PageSize) if err != nil { return nil, err } diff --git a/shortdrama/service/agent_service.go b/shortdrama/service/agent_service.go index dfa7770..2df54ea 100644 --- a/shortdrama/service/agent_service.go +++ b/shortdrama/service/agent_service.go @@ -10,6 +10,7 @@ import ( "video-factory/shortdrama/consts/public" "video-factory/shortdrama/dao" + "video-factory/shortdrama/model/dto" "video-factory/shortdrama/model/entity" "github.com/gogf/gf/v2/database/gdb" @@ -192,6 +193,106 @@ func (s *agentService) RenewAgentByOrder(ctx context.Context, agentId int64, raw return s.RenewAgent(ctx, agentId, params.Duration) } +// ListAgents 分页查询代理商列表 +func (s *agentService) ListAgents(ctx context.Context, req *dto.ListAgentReq) (*dto.ListAgentRes, error) { + rows, total, err := dao.AgentProfile.ListAgentWithProfile(ctx, req.Keyword, req.Phone, req.Province, req.Region, req.ExpiredAtFrom, req.ExpiredAtTo, req.Page, req.PageSize) + if err != nil { + return nil, err + } + list := make([]*dto.ListAgentItem, len(rows)) + for i, r := range rows { + list[i] = &dto.ListAgentItem{ + User: &entity.User{ + Id: r.Id, + Username: r.Username, + Phone: r.Phone, + Name: r.Name, + Province: r.Province, + Region: r.Region, + ExpiredAt: r.ExpiredAt, + }, + RegionProtected: r.RegionProtected, + MaxCustomers: r.MaxCustomers, + } + } + return &dto.ListAgentRes{List: list, Total: total, Page: req.Page, PageSize: req.PageSize}, nil +} + +// UpdateAgent 更新代理商信息 +func (s *agentService) UpdateAgent(ctx context.Context, req *dto.UpdateAgentReq) error { + if req.Name != "" { + m := g.Map{ + "name": req.Name, + "region": req.Region, + } + if req.Phone != "" { + m["phone"] = req.Phone + } + if req.Province != "" { + m["province"] = req.Province + } + if err := dao.User.UpdateFields(ctx, req.Id, m); err != nil { + return err + } + } + + profile, _ := dao.AgentProfile.Get(ctx, req.Id) + if profile == nil { + maxCustomers := 0 + if pricing, _ := dao.RegionPricing.GetByRegion(ctx, req.Region, 0); pricing != nil { + maxCustomers = pricing.MaxCustomers + } + _ = dao.AgentProfile.Save(ctx, &entity.AgentProfile{ + UserId: req.Id, + MaxCustomers: maxCustomers, + Renewals: 0, + ExpiredAt: gtime.Now().AddDate(1, 0, 0), + RegionProtected: false, + }) + } + return nil +} + +// GetAgentDetail 获取代理商详情 +func (s *agentService) GetAgentDetail(ctx context.Context, id int64) (*entity.User, *entity.AgentProfile, error) { + user, err := dao.User.GetOne(ctx, id) + if err != nil || user == nil { + return nil, nil, err + } + profile, _ := dao.AgentProfile.Get(ctx, id) + return user, profile, nil +} + +// ListAgentRenewals 查询代理商续费记录 +func (s *agentService) ListAgentRenewals(ctx context.Context, agentId int64) (*dto.ListRenewalsRes, error) { + orders, err := dao.PaymentOrder.ListByUserAndType(ctx, agentId, "renewal") + if err != nil { + return nil, err + } + list := make([]*dto.RenewalRecord, len(orders)) + for i, o := range orders { + paidAt := "" + if o.PaidAt != nil { + paidAt = o.PaidAt.Format("Y-m-d H:i:s") + } + createdAt := "" + if o.CreatedAt != nil { + createdAt = o.CreatedAt.Format("Y-m-d H:i:s") + } + list[i] = &dto.RenewalRecord{ + Id: o.Id, + OrderNo: o.OrderNo, + Amount: o.Amount, + Channel: o.Channel, + Status: o.Status, + Subject: o.Subject, + PaidAt: paidAt, + CreatedAt: createdAt, + } + } + return &dto.ListRenewalsRes{List: list}, nil +} + // bcryptGenerate 辅助函数:生成 bcrypt 密码哈希 func bcryptGenerate(password string) ([]byte, error) { return bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) diff --git a/shortdrama/service/customer_service.go b/shortdrama/service/customer_service.go index d5f943a..18a7a8b 100644 --- a/shortdrama/service/customer_service.go +++ b/shortdrama/service/customer_service.go @@ -6,6 +6,7 @@ import ( "fmt" "video-factory/shortdrama/dao" + "video-factory/shortdrama/model/dto" "video-factory/shortdrama/model/entity" "github.com/gogf/gf/v2/database/gdb" @@ -119,3 +120,70 @@ func (s *customerService) DeductBalance(ctx context.Context, customerId int64, a }) return err } + +// ListCustomers 分页查询客户列表 +func (s *customerService) ListCustomers(ctx context.Context, req *dto.ListCustomerReq) (*dto.ListCustomerRes, error) { + rows, total, err := dao.CustomerProfile.ListCustomerWithAgent(ctx, req.AgentId, req.Keyword, req.Phone, req.Province, req.Region, req.AgentName, req.Page, req.PageSize) + if err != nil { + return nil, err + } + items := make([]*dto.ListCustomerItem, len(rows)) + for i, r := range rows { + createdAt := "" + if r.CreatedAt != nil { + createdAt = r.CreatedAt.Format("Y-m-d H:i:s") + } + items[i] = &dto.ListCustomerItem{ + Id: r.Id, + Phone: r.Phone, + Name: r.Name, + Province: r.Province, + Region: r.Region, + Address: r.Address, + AgentId: r.AgentId, + AgentName: r.AgentName, + Balance: r.Balance, + CreatedAt: createdAt, + } + } + return &dto.ListCustomerRes{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil +} + +// GetCustomerDetail 获取客户详情 +func (s *customerService) GetCustomerDetail(ctx context.Context, id int64) (*dto.CustomerDetail, error) { + user, err := dao.User.GetOne(ctx, id) + if err != nil || user == nil { + return nil, err + } + profile, _ := dao.CustomerProfile.Get(ctx, id) + balance := s.GetBalance(ctx, id) + return &dto.CustomerDetail{User: user, Profile: profile, Balance: balance}, nil +} + +// UpdateCustomer 更新客户信息 +func (s *customerService) UpdateCustomer(ctx context.Context, agentId int64, req *dto.UpdateCustomerReq) error { + if ap, _ := dao.AgentProfile.Get(ctx, agentId); ap != nil && ap.ExpiredAt != nil && ap.ExpiredAt.Before(gtime.Now()) { + return errors.New("代理商已过期,无法编辑客户") + } + + m := g.Map{} + if req.Phone != "" { + m["phone"] = req.Phone + } + if req.Name != "" { + m["name"] = req.Name + } + if req.Address != "" { + m["address"] = req.Address + province, region, err := AgentService.ExtractRegionFromAddress(ctx, req.Address) + if err != nil { + return err + } + m["province"] = province + m["region"] = region + } + if len(m) == 0 { + return nil + } + return dao.User.UpdateFields(ctx, req.Id, m) +} diff --git a/shortdrama/service/generation_service.go b/shortdrama/service/generation_service.go index 076b153..dabff4e 100644 --- a/shortdrama/service/generation_service.go +++ b/shortdrama/service/generation_service.go @@ -770,11 +770,755 @@ func (s *generationService) ListEpisodeTasks(ctx context.Context, epId int64) ([ return dao.GenerationTask.ListByEpisode(ctx, epId) } +// createVideoTask 调用视频生成API提交任务,返回 (taskID, requestBodyJSON, error) + +func (s *generationService) mergeEpisodeVideo(ctx context.Context, epId int64, lastTaskId int64) error { + tasks, err := dao.GenerationTask.ListByEpisode(ctx, epId) + if err != nil { + return err + } + if len(tasks) == 0 { + return fmt.Errorf("没有任务可合并") + } + + // 检查所有段都已完成(允许 lastTaskId 对应的任务为 review 状态) + for _, t := range tasks { + if t.Id == lastTaskId && t.Status == consts.TaskStatusReview { + continue + } + if t.Status != consts.TaskStatusCompleted { + return fmt.Errorf("第%d段任务尚未确认,状态: %s", t.SegmentIdx+1, t.Status) + } + } + + ep, err := dao.Episode.GetOne(ctx, epId) + if err != nil { + return err + } + d, err := dao.Drama.GetOne(ctx, ep.DramaId) + if err != nil { + return err + } + + dramaTitle := d.Title + safeEp := sanitizeDirName(ep.Title) + outputDir, absErr := filepath.Abs(filepath.Join(WorkspaceDir(dramaTitle), "产出视频")) + if absErr != nil { + outputDir = filepath.Join(WorkspaceDir(dramaTitle), "产出视频") + } + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("创建输出目录失败: %w", err) + } + + // 按 segment_idx 顺序收集已下载的视频文件(直接用原文件,不复制) + segFiles := make([]string, 0, len(tasks)) + for _, t := range tasks { + if t.VideoUrl == "" { + continue + } + // VideoUrl 已由 pollPendingVideos 下载到本地 + if strings.HasPrefix(t.VideoUrl, "http://") || strings.HasPrefix(t.VideoUrl, "https://") { + localPath := filepath.Join(outputDir, fmt.Sprintf("%s_seg_%d.mp4", safeEp, t.SegmentIdx)) + if dlErr := s.downloadFile(ctx, t.VideoUrl, localPath); dlErr != nil { + return fmt.Errorf("第%d段视频下载失败: %w", t.SegmentIdx+1, dlErr) + } + segFiles = append(segFiles, localPath) + } else { + absPath, absPathErr := filepath.Abs(t.VideoUrl) + if absPathErr != nil { + segFiles = append(segFiles, t.VideoUrl) + } else { + segFiles = append(segFiles, absPath) + } + } + } + + if len(segFiles) == 0 { + return fmt.Errorf("没有可合并的视频文件") + } + + lastSegFile := segFiles[len(segFiles)-1] + + finalPath := filepath.Join(outputDir, fmt.Sprintf("%s.mp4", safeEp)) + + if len(segFiles) == 1 { + if err := s.copyFile(lastSegFile, finalPath); err != nil { + return fmt.Errorf("复制视频文件失败: %w", err) + } + } else { + if err := s.concatVideos(segFiles, finalPath); err != nil { + return fmt.Errorf("视频合并失败: %w", err) + } + } + + // 叠加背景音乐(如果存在) + if bgmPath, err := s.overlayBackgroundMusic(ctx, d.Id, finalPath); err == nil { + finalPath = bgmPath + g.Log().Infof(ctx, "已叠加背景音乐: %s", finalPath) + } else { + g.Log().Warningf(ctx, "叠加背景音乐失败(跳过): %v", err) + } + + // 事务:更新最后一段 task 状态 + 剧集状态和 video_url + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + // 更新最后一段任务为 completed + if _, e := tx.Model(public.TableNameGenerationTask).Ctx(ctx).Data(g.Map{"status": consts.TaskStatusCompleted}).Where("id", lastTaskId).Update(); e != nil { + return e + } + // 更新剧集状态 + data := g.Map{"status": consts.EpisodeStatusCompleted} + if finalPath != "" { + data["video_url"] = finalPath + } + _, e := tx.Model(public.TableNameEpisode).Ctx(ctx).Data(data).Where("id", epId).Update() + return e + }) + if err != nil { + return err + } + + if ts, e := dao.GenerationTask.ListByEpisode(ctx, epId); e == nil { + setPollCache(ctx, epId, ts) + } + g.Log().Infof(ctx, "视频合并完成: %s", finalPath) + + // 清理分段视频文件 + for _, segFile := range segFiles { + os.Remove(segFile) + } + 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 +} + +// waitForSegmentVideo 等待指定任务的视频生成完成。 +// 轮询视频 API 直到视频就绪,下载到本地,更新 DB 的 video_url。 +// 用于串行模式中让下一段能提取上一段的尾帧作为首帧。 + +// ==================== Helpers ==================== + +// cleanupEpisodeWorkspace 清理本集之前生成的视频文件 +func cleanupEpisodeWorkspace(ctx context.Context, dramaTitle string, epIndex int, epTitle string) { + wsDir := WorkspaceDir(dramaTitle) + + // 清理产出视频文件 + videoDir := filepath.Join(wsDir, "产出视频") + if entries, err := os.ReadDir(videoDir); err == nil { + safeEp := sanitizeDirName(epTitle) + segPrefix := fmt.Sprintf("%s_seg_", safeEp) + for _, e := range entries { + if !e.IsDir() { + name := e.Name() + if strings.HasPrefix(name, segPrefix) || name == safeEp+".mp4" || strings.HasPrefix(name, "concat_") { + _ = os.Remove(filepath.Join(videoDir, name)) + } + } + } + } +} + +func calcSegDurs(episodeDuration int64, cfg *entity.ModelConfig) []int { + // 从 video_schema.duration 读取模型单段时长约束 + effectiveMax := 15 // 默认值 + minSingle := 5 + if cfg.Schema != "" { + var vs map[string]any + if err := json.Unmarshal([]byte(cfg.Schema), &vs); err == nil { + if v := intVal(nested(vs, "body", "parameters", "duration", "max"), 0); v > 0 { + effectiveMax = v + } + if v := intVal(nested(vs, "body", "parameters", "duration", "min"), 0); v > 0 { + minSingle = v + } + } + } + if minSingle > effectiveMax { + minSingle = effectiveMax + } + totalDuration := int(episodeDuration) + if totalDuration <= 0 { + totalDuration = 60 + } + return calcSegmentDurations(totalDuration, effectiveMax, minSingle) +} + +// calcSegmentDurations 将总时长按最大/最小段长拆分为多段 +func calcSegmentDurations(totalDuration, maxSingle, minSingle int) []int { + if totalDuration <= maxSingle { + return []int{totalDuration} + } + + numSegments := int(math.Ceil(float64(totalDuration) / float64(maxSingle))) + if numSegments <= 0 { + numSegments = 1 + } + + base := totalDuration / numSegments + remainder := totalDuration % numSegments + + durations := make([]int, numSegments) + for i := 0; i < numSegments; i++ { + durations[i] = base + if i < remainder { + durations[i]++ + } + } + + // 保证每段不少于 minSingle(从后往前借) + for i := range durations { + if durations[i] < minSingle && i > 0 { + borrow := minSingle - durations[i] + if durations[i-1]-borrow >= minSingle { + durations[i-1] -= borrow + durations[i] += borrow + } + } + } + + return durations +} + +// ==================== Helpers (migrated from utils.go) ==================== + +// formatDramaConfig 将 Config JSON 格式化为可读的提示文字 +func formatDramaConfig(contentType, configStr string) string { + if configStr == "" || configStr == "{}" { + return "" + } + var cfg map[string]interface{} + if err := json.Unmarshal([]byte(configStr), &cfg); err != nil { + return "" + } + formatValue := func(v interface{}) string { + switch val := v.(type) { + case string: + return val + case []interface{}: + parts := make([]string, 0, len(val)) + for _, item := range val { + parts = append(parts, fmt.Sprintf("%v", item)) + } + return strings.Join(parts, "、") + default: + return fmt.Sprintf("%v", v) + } + } + if contentType == "短剧" { + if v, ok := cfg["题材"]; ok { + return "题材:" + formatValue(v) + } + } + if contentType == "漫剧" { + parts := make([]string, 0, 2) + if v, ok := cfg["漫画画风"]; ok { + parts = append(parts, "漫画画风:"+formatValue(v)) + } + if v, ok := cfg["题材"]; ok { + parts = append(parts, "题材:"+formatValue(v)) + } + return strings.Join(parts, ",") + } + if contentType == "广告视频" { + parts := make([]string, 0, 2) + if v, ok := cfg["广告类型"]; ok { + parts = append(parts, "广告类型:"+formatValue(v)) + } + if v, ok := cfg["产品行业"]; ok { + parts = append(parts, "产品行业:"+formatValue(v)) + } + return strings.Join(parts, ",") + } + return "" +} + +// parseMMSSToSeconds 将 MM:SS 格式转为秒数 +func parseMMSSToSeconds(timeStr string) int { + parts := strings.Split(timeStr, ":") + if len(parts) == 2 { + m := 0 + sec := 0 + fmt.Sscanf(parts[0], "%d", &m) + fmt.Sscanf(parts[1], "%d", &sec) + return m*60 + sec + } + return 0 +} + +// ==================== PromptService (migrated from prompt_service.go) ==================== + +const promptFilePath = "prompt.md" + +var ( + promptOnce sync.Once + promptSystem string + promptScript string +) + +func loadPrompt(ctx context.Context) { + data, err := os.ReadFile(promptFilePath) + if err != nil { + g.Log().Warningf(ctx, "读取提示词文件失败: %v", err) + return + } + parts := strings.SplitN(string(data), "\n---\n", 2) + promptSystem = parts[0] + if len(parts) >= 2 { + promptScript = strings.TrimSpace(parts[1]) + } +} + +type promptService struct{} + +var PromptService = new(promptService) + +// GetSystemPrompt 获取导演工作流系统提示词(文件内容的第一段,以 --- 分隔) +func (s *promptService) GetSystemPrompt(ctx context.Context) string { + promptOnce.Do(func() { loadPrompt(ctx) }) + return promptSystem +} + +// GetScriptGenerationPrompt 获取剧本生成提示词(文件内容的第二段,以 --- 分隔) +func (s *promptService) GetScriptGenerationPrompt(ctx context.Context) string { + promptOnce.Do(func() { loadPrompt(ctx) }) + return promptScript +} + +// RefIndex 引用索引:{"演员":{"张三":"workspace/.../演员形象/张三.png"}, "场景":{"..."}, "道具":{"..."}} +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 + BackgroundMusic []*entity.BackgroundMusic + + RefIndex RefIndex + 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) + } + + bgmList, err := dao.BackgroundMusic.ListByDrama(ctx, drama.Id) + if err != nil { + return nil, fmt.Errorf("加载背景音失败: %w", err) + } + + ctx2 := &GenerationContext{ + Drama: drama, + Characters: characters, + Scenes: scenes, + Props: props, + BackgroundMusic: bgmList, + RefIndex: make(RefIndex), + } + + if err := ctx2.buildRefIndex(); err != nil { + return nil, err + } + + return ctx2, nil +} + +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 + } + if existing, ok := charIdx[ch.Name]; ok { + return fmt.Errorf("演员名冲突: '%s' (已有形象路径 %s,重复 %s)", ch.Name, existing, ch.PortraitPath) + } + charIdx[ch.Name] = ch.PortraitPath + } + if len(charIdx) > 0 { + c.RefIndex["演员"] = charIdx + } + + sceneIdx := make(map[string]string, len(c.Scenes)) + for _, sc := range c.Scenes { + if sc.ImagePath == "" { + continue + } + if _, ok := sceneIdx[sc.Name]; ok { + return fmt.Errorf("场景名冲突: '%s'", sc.Name) + } + sceneIdx[sc.Name] = sc.ImagePath + } + if len(sceneIdx) > 0 { + c.RefIndex["场景"] = sceneIdx + } + + propIdx := make(map[string]string, len(c.Props)) + for _, p := range c.Props { + if p.ImagePath == "" { + continue + } + if _, ok := propIdx[p.Name]; ok { + return fmt.Errorf("道具名冲突: '%s'", p.Name) + } + propIdx[p.Name] = p.ImagePath + } + if len(propIdx) > 0 { + c.RefIndex["道具"] = propIdx + } + + idx := 0 + for _, category := range []string{"演员", "场景", "道具"} { + m, ok := c.RefIndex[category] + if !ok { + continue + } + 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 中的索引位置 +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 +} + +// ==================== Background Music ==================== + +// overlayBackgroundMusic 使用 ffmpeg 为视频叠加背景音乐(循环混音) +func (s *generationService) overlayBackgroundMusic(ctx context.Context, dramaId int64, videoPath string) (string, error) { + bgmList, err := dao.BackgroundMusic.ListByDrama(ctx, dramaId) + if err != nil || len(bgmList) == 0 { + return "", fmt.Errorf("无背景音乐配置") + } + bgmPath := bgmList[0].FilePath + if bgmPath == "" { + return "", fmt.Errorf("背景音乐文件路径为空") + } + if _, err := os.Stat(bgmPath); os.IsNotExist(err) { + return "", fmt.Errorf("背景音乐文件不存在: %s", bgmPath) + } + if _, err := exec.LookPath("ffmpeg"); err != nil { + return "", fmt.Errorf("ffmpeg 不可用: %w", err) + } + + ext := filepath.Ext(videoPath) + bgmOutput := strings.TrimSuffix(videoPath, ext) + "_bgm" + ext + cmd := exec.Command("ffmpeg", + "-stream_loop", "-1", + "-i", bgmPath, + "-i", videoPath, + "-filter_complex", "[0:a]volume=0.15[bgm];[1:a][bgm]amix=inputs=2:duration=first[audio]", + "-map", "1:v", + "-map", "[audio]", + "-c:v", "copy", + "-shortest", + "-y", + bgmOutput, + ) + if output, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("ffmpeg 失败: %w, 输出: %s", err, string(output)) + } + + if err := os.Rename(bgmOutput, videoPath); err != nil { + return "", fmt.Errorf("替换视频文件失败: %w", err) + } + return videoPath, nil +} + +// ==================== FFMPEG Helpers ==================== + +// extractLastFrame 使用 ffmpeg 截取视频尾帧并返回 base64 data URL(不落盘) +func extractLastFrame(ctx context.Context, videoPath string) (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) + } + cmd := exec.Command("ffmpeg", + "-sseof", "-0.1", + "-i", videoPath, + "-q:v", "2", + "-vframes", "1", + "-f", "image2pipe", + "-", + ) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("ffmpeg 截取尾帧失败: %w", err) + } + 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 + } + + 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 +} + +// concatVideos 使用 ffmpeg concat 协议合并多个视频 +func (s *generationService) concatVideos(inputs []string, output string) error { + if len(inputs) < 2 { + return fmt.Errorf("需要至少2个输入文件才能合并") + } + + filelist := output + ".filelist.txt" + var lines []string + for _, path := range inputs { + absPath, err := filepath.Abs(path) + if err != nil { + absPath = path + } + escaped := strings.ReplaceAll(absPath, "'", "'\\''") + lines = append(lines, "file '"+escaped+"'") + } + if err := os.WriteFile(filelist, []byte(strings.Join(lines, "\n")+"\n"), 0644); err != nil { + return fmt.Errorf("创建文件列表失败: %w", err) + } + defer os.Remove(filelist) + + cmd := exec.Command("ffmpeg", + "-f", "concat", + "-safe", "0", + "-i", filelist, + "-c", "copy", + "-y", output, + ) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("ffmpeg concat 失败: %w, 输出: %s", err, string(out)) + } + return nil +} + +// downloadFile 下载远程文件到本地并验证视频有效性 +func (s *generationService) downloadFile(ctx context.Context, url, dest string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode) + } + + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + return err + } + + info, err := os.Stat(dest) + if err != nil { + return err + } + if info.Size() == 0 { + return fmt.Errorf("下载的文件为空") + } + + if err := s.probeVideo(dest); err != nil { + return fmt.Errorf("下载的视频文件无效: %w", err) + } + + return nil +} + +// probeVideo 使用 ffprobe 验证视频文件 +func (s *generationService) probeVideo(path string) error { + cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("视频文件无效: %w, ffprobe 输出: %s", err, string(out)) + } + return nil +} + +// copyFile 复制文件 +func (s *generationService) copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} + +// ==================== File Helpers ==================== + +// saveBase64Image 将 base64 图片数据保存到工作空间 +func (s *generationService) saveBase64Image(ctx context.Context, dramaTitle, b64Data, subDir, fileName string) (string, error) { + data, err := base64.StdEncoding.DecodeString(b64Data) + if err != nil { + return "", fmt.Errorf("解码 base64 失败: %w", err) + } + 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, data, 0644); err != nil { + return "", fmt.Errorf("写入文件失败: %w", err) + } + return dest, nil +} + +// downloadToLocal 下载远程文件到本地工作空间 +func (s *generationService) downloadToLocal(ctx context.Context, url, dramaTitle, 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 := s.downloadFile(ctx, url, dest); err != nil { + return "", fmt.Errorf("下载文件失败: %w", err) + } + return dest, nil +} + +// imageFileToBase64 读取图片文件并转为 data:image/...;base64 字符串 +func imageFileToBase64(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + ext := strings.ToLower(filepath.Ext(path)) + mime := "image/png" + switch ext { + case ".jpg", ".jpeg": + mime = "image/jpeg" + case ".gif": + mime = "image/gif" + case ".webp": + mime = "image/webp" + } + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil +} + // ==================== Polling ==================== const pollCacheTTL = 30 * time.Second -// setPollCache 写入剧集轮询缓存(根据所有任务状态聚合 episode 级别状态) func setPollCache(ctx context.Context, epId int64, tasks []*entity.GenerationTask) { if len(tasks) == 0 { return @@ -1101,6 +1845,50 @@ func (s *generationService) pollVideoTaskOnce(ctx context.Context, modelCfg *Mer // 通过 API 响应中的 code/message 查看具体错误 return "", fmt.Errorf("任务异常: %s - %s", result.Code, result.Message) } +func (s *generationService) waitForSegmentVideo(ctx context.Context, d *entity.Drama, ep *entity.Episode, taskId int64) error { + modelCfg := UserModelConfigService.GetMergedConfig(ctx, d.UserId, "video") + if modelCfg.ApiKey == "" || modelCfg.BaseUrl == "" { + return fmt.Errorf("视频模型未配置或查询地址为空") + } + + task, err := dao.GenerationTask.GetOne(ctx, taskId) + if err != nil || task == nil { + return fmt.Errorf("获取任务失败: %w", err) + } + if task.VideoTaskId == "" { + return fmt.Errorf("任务 %d 尚无 video_task_id", taskId) + } + + videoTaskID := task.VideoTaskId + maxAttempts := 50 // 约 5 分钟(每次轮询间隔 6s) + for attempt := 0; attempt < maxAttempts; attempt++ { + videoURL, err := s.pollVideoTaskOnce(ctx, modelCfg, videoTaskID) + if err == nil && videoURL != "" { + // 视频就绪,下载到本地 + dramaTitle := sanitizeDirName(d.Title) + safeEp := sanitizeDirName(ep.Title) + localPath, dlErr := s.downloadToLocal(ctx, videoURL, dramaTitle, "产出视频", + fmt.Sprintf("%s_seg_%d.mp4", safeEp, task.SegmentIdx)) + if dlErr != nil { + g.Log().Warningf(ctx, "串行等待: 任务 %d 第%d段视频下载失败,使用远程地址: %v", task.Id, task.SegmentIdx+1, dlErr) + _ = dao.GenerationTask.UpdateFields(ctx, task.Id, g.Map{"video_url": videoURL, "video_task_id": ""}) + } else { + g.Log().Infof(ctx, "串行等待: 任务 %d 第%d段视频已下载: %s", task.Id, task.SegmentIdx+1, localPath) + _ = dao.GenerationTask.UpdateFields(ctx, task.Id, g.Map{"video_url": localPath, "video_task_id": ""}) + } + return nil + } + if err != nil { + // 非 duration 错误才终止 + if !strings.Contains(strings.ToLower(err.Error()), "duration") { + return fmt.Errorf("轮询视频任务失败: %w", err) + } + g.Log().Warningf(ctx, "串行等待: 第%d段视频任务 duration 错误,重试: %v", task.SegmentIdx+1, err) + } + time.Sleep(6 * time.Second) + } + return fmt.Errorf("串行等待超时: 任务 %d 第%d段视频在 %d 次轮询后仍未就绪", task.Id, task.SegmentIdx+1, maxAttempts) +} // ==================== Video Generation ==================== // submitFromExistingScript 复用 task.script 提交视频任务,只注入首帧参考 + 转换 base64 @@ -1286,7 +2074,6 @@ func (s *generationService) submitVideoTask(ctx context.Context, d *entity.Drama return taskId, requestJSON, nil } -// createVideoTask 调用视频生成API提交任务,返回 (taskID, requestBodyJSON, error) // API 请求体结构由 model_config.video_schema 定义,包含: // // params: 额外参数(如 audio/shot_type/watermark 等) @@ -1852,813 +2639,5 @@ func intVal(v any, fallback int) int { default: return fallback } -} -func (s *generationService) mergeEpisodeVideo(ctx context.Context, epId int64, lastTaskId int64) error { - tasks, err := dao.GenerationTask.ListByEpisode(ctx, epId) - if err != nil { - return err - } - if len(tasks) == 0 { - return fmt.Errorf("没有任务可合并") - } - - // 检查所有段都已完成(允许 lastTaskId 对应的任务为 review 状态) - for _, t := range tasks { - if t.Id == lastTaskId && t.Status == consts.TaskStatusReview { - continue - } - if t.Status != consts.TaskStatusCompleted { - return fmt.Errorf("第%d段任务尚未确认,状态: %s", t.SegmentIdx+1, t.Status) - } - } - - ep, err := dao.Episode.GetOne(ctx, epId) - if err != nil { - return err - } - d, err := dao.Drama.GetOne(ctx, ep.DramaId) - if err != nil { - return err - } - - dramaTitle := d.Title - safeEp := sanitizeDirName(ep.Title) - outputDir, absErr := filepath.Abs(filepath.Join(WorkspaceDir(dramaTitle), "产出视频")) - if absErr != nil { - outputDir = filepath.Join(WorkspaceDir(dramaTitle), "产出视频") - } - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("创建输出目录失败: %w", err) - } - - // 按 segment_idx 顺序收集已下载的视频文件(直接用原文件,不复制) - segFiles := make([]string, 0, len(tasks)) - for _, t := range tasks { - if t.VideoUrl == "" { - continue - } - // VideoUrl 已由 pollPendingVideos 下载到本地 - if strings.HasPrefix(t.VideoUrl, "http://") || strings.HasPrefix(t.VideoUrl, "https://") { - localPath := filepath.Join(outputDir, fmt.Sprintf("%s_seg_%d.mp4", safeEp, t.SegmentIdx)) - if dlErr := s.downloadFile(ctx, t.VideoUrl, localPath); dlErr != nil { - return fmt.Errorf("第%d段视频下载失败: %w", t.SegmentIdx+1, dlErr) - } - segFiles = append(segFiles, localPath) - } else { - absPath, absPathErr := filepath.Abs(t.VideoUrl) - if absPathErr != nil { - segFiles = append(segFiles, t.VideoUrl) - } else { - segFiles = append(segFiles, absPath) - } - } - } - - if len(segFiles) == 0 { - return fmt.Errorf("没有可合并的视频文件") - } - - lastSegFile := segFiles[len(segFiles)-1] - - finalPath := filepath.Join(outputDir, fmt.Sprintf("%s.mp4", safeEp)) - - if len(segFiles) == 1 { - if err := s.copyFile(lastSegFile, finalPath); err != nil { - return fmt.Errorf("复制视频文件失败: %w", err) - } - } else { - if err := s.concatVideos(segFiles, finalPath); err != nil { - return fmt.Errorf("视频合并失败: %w", err) - } - } - - // 叠加背景音乐(如果存在) - if bgmPath, err := s.overlayBackgroundMusic(ctx, d.Id, finalPath); err == nil { - finalPath = bgmPath - g.Log().Infof(ctx, "已叠加背景音乐: %s", finalPath) - } else { - g.Log().Warningf(ctx, "叠加背景音乐失败(跳过): %v", err) - } - - // 事务:更新最后一段 task 状态 + 剧集状态和 video_url - err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - // 更新最后一段任务为 completed - if _, e := tx.Model(public.TableNameGenerationTask).Ctx(ctx).Data(g.Map{"status": consts.TaskStatusCompleted}).Where("id", lastTaskId).Update(); e != nil { - return e - } - // 更新剧集状态 - data := g.Map{"status": consts.EpisodeStatusCompleted} - if finalPath != "" { - data["video_url"] = finalPath - } - _, e := tx.Model(public.TableNameEpisode).Ctx(ctx).Data(data).Where("id", epId).Update() - return e - }) - if err != nil { - return err - } - - if ts, e := dao.GenerationTask.ListByEpisode(ctx, epId); e == nil { - setPollCache(ctx, epId, ts) - } - g.Log().Infof(ctx, "视频合并完成: %s", finalPath) - - // 清理分段视频文件 - for _, segFile := range segFiles { - os.Remove(segFile) - } - 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 -} - -// ==================== Video Helpers ==================== - -// overlayBackgroundMusic 使用 ffmpeg 为视频叠加背景音乐(循环混音) -func (s *generationService) overlayBackgroundMusic(ctx context.Context, dramaId int64, videoPath string) (string, error) { - bgmList, err := dao.BackgroundMusic.ListByDrama(ctx, dramaId) - if err != nil || len(bgmList) == 0 { - return "", fmt.Errorf("无背景音乐配置") - } - bgmPath := bgmList[0].FilePath - if bgmPath == "" { - return "", fmt.Errorf("背景音乐文件路径为空") - } - if _, err := os.Stat(bgmPath); os.IsNotExist(err) { - return "", fmt.Errorf("背景音乐文件不存在: %s", bgmPath) - } - // 检查 ffmpeg 是否可用 - if _, err := exec.LookPath("ffmpeg"); err != nil { - return "", fmt.Errorf("ffmpeg 不可用: %w", err) - } - - ext := filepath.Ext(videoPath) - bgmOutput := strings.TrimSuffix(videoPath, ext) + "_bgm" + ext - // ffmpeg 命令:循环 BGM 并降低音量,与视频原音频混音 - cmd := exec.Command("ffmpeg", - "-stream_loop", "-1", - "-i", bgmPath, - "-i", videoPath, - "-filter_complex", "[0:a]volume=0.15[bgm];[1:a][bgm]amix=inputs=2:duration=first[audio]", - "-map", "1:v", - "-map", "[audio]", - "-c:v", "copy", - "-shortest", - "-y", - bgmOutput, - ) - if output, err := cmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("ffmpeg 失败: %w, 输出: %s", err, string(output)) - } - - // 替换原文件 - if err := os.Rename(bgmOutput, videoPath); err != nil { - // 如果替换失败,尝试移除原文件后重命名 - os.Remove(videoPath) - if err2 := os.Rename(bgmOutput, videoPath); err2 != nil { - os.Remove(bgmOutput) - return "", fmt.Errorf("替换视频文件失败: %w", err2) - } - } - return videoPath, nil -} - -// extractLastFrame 使用 ffmpeg 截取视频尾帧并返回 base64 data URL(不落盘) -func extractLastFrame(ctx context.Context, videoPath string) (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) - } - cmd := exec.Command("ffmpeg", - "-sseof", "-0.1", - "-i", videoPath, - "-q:v", "2", - "-vframes", "1", - "-f", "image2pipe", - "-", - ) - out, err := cmd.Output() - if err != nil { - if ee, ok := err.(*exec.ExitError); ok { - return "", fmt.Errorf("ffmpeg 截取尾帧失败: %w, stderr: %s", err, string(ee.Stderr)) - } - return "", fmt.Errorf("ffmpeg 截取尾帧失败: %w", err) - } - 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 -} - -// waitForSegmentVideo 等待指定任务的视频生成完成。 -// 轮询视频 API 直到视频就绪,下载到本地,更新 DB 的 video_url。 -// 用于串行模式中让下一段能提取上一段的尾帧作为首帧。 -func (s *generationService) waitForSegmentVideo(ctx context.Context, d *entity.Drama, ep *entity.Episode, taskId int64) error { - modelCfg := UserModelConfigService.GetMergedConfig(ctx, d.UserId, "video") - if modelCfg.ApiKey == "" || modelCfg.BaseUrl == "" { - return fmt.Errorf("视频模型未配置或查询地址为空") - } - - task, err := dao.GenerationTask.GetOne(ctx, taskId) - if err != nil || task == nil { - return fmt.Errorf("获取任务失败: %w", err) - } - if task.VideoTaskId == "" { - return fmt.Errorf("任务 %d 尚无 video_task_id", taskId) - } - - videoTaskID := task.VideoTaskId - maxAttempts := 50 // 约 5 分钟(每次轮询间隔 6s) - for attempt := 0; attempt < maxAttempts; attempt++ { - videoURL, err := s.pollVideoTaskOnce(ctx, modelCfg, videoTaskID) - if err == nil && videoURL != "" { - // 视频就绪,下载到本地 - dramaTitle := sanitizeDirName(d.Title) - safeEp := sanitizeDirName(ep.Title) - localPath, dlErr := s.downloadToLocal(ctx, videoURL, dramaTitle, "产出视频", - fmt.Sprintf("%s_seg_%d.mp4", safeEp, task.SegmentIdx)) - if dlErr != nil { - g.Log().Warningf(ctx, "串行等待: 任务 %d 第%d段视频下载失败,使用远程地址: %v", task.Id, task.SegmentIdx+1, dlErr) - _ = dao.GenerationTask.UpdateFields(ctx, task.Id, g.Map{"video_url": videoURL, "video_task_id": ""}) - } else { - g.Log().Infof(ctx, "串行等待: 任务 %d 第%d段视频已下载: %s", task.Id, task.SegmentIdx+1, localPath) - _ = dao.GenerationTask.UpdateFields(ctx, task.Id, g.Map{"video_url": localPath, "video_task_id": ""}) - } - return nil - } - if err != nil { - // 非 duration 错误才终止 - if !strings.Contains(strings.ToLower(err.Error()), "duration") { - return fmt.Errorf("轮询视频任务失败: %w", err) - } - g.Log().Warningf(ctx, "串行等待: 第%d段视频任务 duration 错误,重试: %v", task.SegmentIdx+1, err) - } - time.Sleep(6 * time.Second) - } - return fmt.Errorf("串行等待超时: 任务 %d 第%d段视频在 %d 次轮询后仍未就绪", task.Id, task.SegmentIdx+1, maxAttempts) -} - -func (s *generationService) concatVideos(inputs []string, output string) error { - if len(inputs) < 2 { - return fmt.Errorf("需要至少2个输入文件才能合并") - } - - // 创建 ffmpeg concat demuxer 文件列表 - filelist := output + ".filelist.txt" - var lines []string - for _, path := range inputs { - absPath, err := filepath.Abs(path) - if err != nil { - absPath = path - } - escaped := strings.ReplaceAll(absPath, "'", "'\\''") - lines = append(lines, "file '"+escaped+"'") - } - if err := os.WriteFile(filelist, []byte(strings.Join(lines, "\n")+"\n"), 0644); err != nil { - return fmt.Errorf("创建文件列表失败: %w", err) - } - defer os.Remove(filelist) - - cmd := exec.Command("ffmpeg", - "-f", "concat", - "-safe", "0", - "-i", filelist, - "-c", "copy", - "-y", output, - ) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("ffmpeg concat 失败: %w, 输出: %s", err, string(out)) - } - return nil -} - -func (s *generationService) downloadFile(ctx context.Context, url, dest string) error { - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode) - } - - out, err := os.Create(dest) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, resp.Body) - if err != nil { - return err - } - - info, err := os.Stat(dest) - if err != nil { - return err - } - if info.Size() == 0 { - return fmt.Errorf("下载的文件为空") - } - - if err := s.probeVideo(dest); err != nil { - return fmt.Errorf("下载的视频文件无效: %w", err) - } - - return nil -} - -func (s *generationService) probeVideo(path string) error { - cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("视频文件无效: %w, ffprobe 输出: %s", err, string(out)) - } - return nil -} - -func (s *generationService) copyFile(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, in) - return err -} - -// ==================== Helpers ==================== - -// cleanupEpisodeWorkspace 清理本集之前生成的视频文件 -func cleanupEpisodeWorkspace(ctx context.Context, dramaTitle string, epIndex int, epTitle string) { - wsDir := WorkspaceDir(dramaTitle) - - // 清理产出视频文件 - videoDir := filepath.Join(wsDir, "产出视频") - if entries, err := os.ReadDir(videoDir); err == nil { - safeEp := sanitizeDirName(epTitle) - segPrefix := fmt.Sprintf("%s_seg_", safeEp) - for _, e := range entries { - if !e.IsDir() { - name := e.Name() - if strings.HasPrefix(name, segPrefix) || name == safeEp+".mp4" || strings.HasPrefix(name, "concat_") { - _ = os.Remove(filepath.Join(videoDir, name)) - } - } - } - } -} - -func calcSegDurs(episodeDuration int64, cfg *entity.ModelConfig) []int { - // 从 video_schema.duration 读取模型单段时长约束 - effectiveMax := 15 // 默认值 - minSingle := 5 - if cfg.Schema != "" { - var vs map[string]any - if err := json.Unmarshal([]byte(cfg.Schema), &vs); err == nil { - if v := intVal(nested(vs, "body", "parameters", "duration", "max"), 0); v > 0 { - effectiveMax = v - } - if v := intVal(nested(vs, "body", "parameters", "duration", "min"), 0); v > 0 { - minSingle = v - } - } - } - if minSingle > effectiveMax { - minSingle = effectiveMax - } - totalDuration := int(episodeDuration) - if totalDuration <= 0 { - totalDuration = 60 - } - return calcSegmentDurations(totalDuration, effectiveMax, minSingle) -} - -// calcSegmentDurations 将总时长按最大/最小段长拆分为多段 -func calcSegmentDurations(totalDuration, maxSingle, minSingle int) []int { - if totalDuration <= maxSingle { - return []int{totalDuration} - } - - numSegments := int(math.Ceil(float64(totalDuration) / float64(maxSingle))) - if numSegments <= 0 { - numSegments = 1 - } - - base := totalDuration / numSegments - remainder := totalDuration % numSegments - - durations := make([]int, numSegments) - for i := 0; i < numSegments; i++ { - durations[i] = base - if i < remainder { - durations[i]++ - } - } - - // 保证每段不少于 minSingle(从后往前借) - for i := range durations { - if durations[i] < minSingle && i > 0 { - borrow := minSingle - durations[i] - if durations[i-1]-borrow >= minSingle { - durations[i-1] -= borrow - durations[i] += borrow - } - } - } - - return durations -} - -func (s *generationService) saveBase64Image(ctx context.Context, dramaTitle, b64Data, subDir, fileName string) (string, error) { - data, err := base64.StdEncoding.DecodeString(b64Data) - if err != nil { - return "", fmt.Errorf("解码 base64 失败: %w", err) - } - 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, data, 0644); err != nil { - return "", fmt.Errorf("写入文件失败: %w", err) - } - return dest, nil -} - -// downloadToLocal 下载远程文件到本地 -func (s *generationService) downloadToLocal(ctx context.Context, url, dramaTitle, 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 := s.downloadFile(ctx, url, dest); err != nil { - return "", fmt.Errorf("下载文件失败: %w", err) - } - return dest, nil -} - -// imageFileToBase64 读取图片文件并转为 data:image/...;base64 字符串 -func imageFileToBase64(path string) (string, error) { - data, err := os.ReadFile(path) - if err != nil { - return "", err - } - ext := strings.ToLower(filepath.Ext(path)) - mime := "image/png" - switch ext { - case ".jpg", ".jpeg": - mime = "image/jpeg" - case ".gif": - mime = "image/gif" - case ".webp": - mime = "image/webp" - } - return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil -} - -// ==================== Helpers (migrated from utils.go) ==================== - -// formatDramaConfig 将 Config JSON 格式化为可读的提示文字 -func formatDramaConfig(contentType, configStr string) string { - if configStr == "" || configStr == "{}" { - return "" - } - var cfg map[string]interface{} - if err := json.Unmarshal([]byte(configStr), &cfg); err != nil { - return "" - } - formatValue := func(v interface{}) string { - switch val := v.(type) { - case string: - return val - case []interface{}: - parts := make([]string, 0, len(val)) - for _, item := range val { - parts = append(parts, fmt.Sprintf("%v", item)) - } - return strings.Join(parts, "、") - default: - return fmt.Sprintf("%v", v) - } - } - if contentType == "短剧" { - if v, ok := cfg["题材"]; ok { - return "题材:" + formatValue(v) - } - } - if contentType == "漫剧" { - parts := make([]string, 0, 2) - if v, ok := cfg["漫画画风"]; ok { - parts = append(parts, "漫画画风:"+formatValue(v)) - } - if v, ok := cfg["题材"]; ok { - parts = append(parts, "题材:"+formatValue(v)) - } - return strings.Join(parts, ",") - } - if contentType == "广告视频" { - parts := make([]string, 0, 2) - if v, ok := cfg["广告类型"]; ok { - parts = append(parts, "广告类型:"+formatValue(v)) - } - if v, ok := cfg["产品行业"]; ok { - parts = append(parts, "产品行业:"+formatValue(v)) - } - return strings.Join(parts, ",") - } - return "" -} - -// parseMMSSToSeconds 将 MM:SS 格式转为秒数 -func parseMMSSToSeconds(timeStr string) int { - parts := strings.Split(timeStr, ":") - if len(parts) == 2 { - m := 0 - sec := 0 - fmt.Sscanf(parts[0], "%d", &m) - fmt.Sscanf(parts[1], "%d", &sec) - return m*60 + sec - } - return 0 -} - -// ==================== PromptService (migrated from prompt_service.go) ==================== - -const promptFilePath = "prompt.md" - -type promptService struct { - mu sync.RWMutex - cached string -} - -var PromptService = new(promptService) - -func (s *promptService) getRawContent(ctx context.Context) string { - s.mu.RLock() - cached := s.cached - s.mu.RUnlock() - if cached != "" { - return cached - } - - s.mu.Lock() - defer s.mu.Unlock() - - if s.cached != "" { - return s.cached - } - - data, err := os.ReadFile(promptFilePath) - if err != nil { - g.Log().Warningf(ctx, "读取提示词文件失败: %v", err) - return "" - } - s.cached = string(data) - return s.cached -} - -// GetSystemPrompt 获取导演工作流系统提示词(文件内容的第一段,以 --- 分隔) -func (s *promptService) GetSystemPrompt(ctx context.Context) string { - raw := s.getRawContent(ctx) - parts := strings.SplitN(raw, "\n---\n", 2) - return parts[0] -} - -// GetScriptGenerationPrompt 获取剧本生成提示词(文件内容的第二段,以 --- 分隔) -func (s *promptService) GetScriptGenerationPrompt(ctx context.Context) string { - raw := s.getRawContent(ctx) - parts := strings.SplitN(raw, "\n---\n", 2) - if len(parts) < 2 { - return raw - } - return strings.TrimSpace(parts[1]) -} - -// ==================== GenerationContext (migrated from generation_context.go) ==================== - -// RefIndex 引用索引:{"演员":{"张三":"workspace/.../演员形象/张三.png"}, "场景":{"...", "道具":{"..."}}} -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 - BackgroundMusic []*entity.BackgroundMusic - - RefIndex RefIndex - 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) - } - - bgmList, err := dao.BackgroundMusic.ListByDrama(ctx, drama.Id) - if err != nil { - return nil, fmt.Errorf("加载背景音失败: %w", err) - } - - ctx2 := &GenerationContext{ - Drama: drama, - Characters: characters, - Scenes: scenes, - Props: props, - BackgroundMusic: bgmList, - RefIndex: make(RefIndex), - } - - if err := ctx2.buildRefIndex(); err != nil { - return nil, err - } - - return ctx2, nil -} - -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 - } - if existing, ok := charIdx[ch.Name]; ok { - return fmt.Errorf("演员名冲突: '%s' (已有形象路径 %s,重复 %s)", ch.Name, existing, ch.PortraitPath) - } - charIdx[ch.Name] = ch.PortraitPath - } - if len(charIdx) > 0 { - c.RefIndex["演员"] = charIdx - } - - sceneIdx := make(map[string]string, len(c.Scenes)) - for _, sc := range c.Scenes { - if sc.ImagePath == "" { - continue - } - if _, ok := sceneIdx[sc.Name]; ok { - return fmt.Errorf("场景名冲突: '%s'", sc.Name) - } - sceneIdx[sc.Name] = sc.ImagePath - } - if len(sceneIdx) > 0 { - c.RefIndex["场景"] = sceneIdx - } - - propIdx := make(map[string]string, len(c.Props)) - for _, p := range c.Props { - if p.ImagePath == "" { - continue - } - if _, ok := propIdx[p.Name]; ok { - return fmt.Errorf("道具名冲突: '%s'", p.Name) - } - propIdx[p.Name] = p.ImagePath - } - if len(propIdx) > 0 { - c.RefIndex["道具"] = propIdx - } - - idx := 0 - for _, category := range []string{"演员", "场景", "道具"} { - m, ok := c.RefIndex[category] - if !ok { - continue - } - 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 中的索引位置 -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 }