// 本文件负责按创作项目类型筛选当前剧集可下载的视频,并生成 ZIP 文件清单。 package service import ( "errors" "fmt" "path" "strings" "juhe-factory/api/internal/videoarchive" "github.com/google/uuid" "gorm.io/gorm" ) // episodeVideoArchiveRow 描述生成视频查询结果及其在分镜中的业务状态。 type episodeVideoArchiveRow struct { ObjectKey string MimeType string SequenceNo int Active bool } // EpisodeVideoArchive 查询当前剧集视频:短剧创作仅包含主视频和备选视频,视频转绘包含主视频和历史视频。 func (s *Creative) EpisodeVideoArchive(userID, projectID, episodeID uuid.UUID) (videoarchive.Manifest, error) { // 校验项目归属并获取项目类型,当前剧集视频 ZIP 仅对短剧创作和视频转绘开放。 var project struct { ProjectType string } if err := s.DB.Table("creative_projects").Select("project_type"). Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID). Take(&project).Error; err != nil { return videoarchive.Manifest{}, err } if project.ProjectType != "premium_drama" && project.ProjectType != "video_redraw" { return videoarchive.Manifest{}, gorm.ErrRecordNotFound } var episode struct { ProjectName string EpisodeName string } if err := s.DB.Table("project_episodes episode"). Select("project.name AS project_name,episode.name AS episode_name"). Joins("JOIN creative_projects project ON project.id=episode.project_id AND project.deleted_at IS NULL"). Where("episode.id=? AND episode.project_id=? AND episode.deleted_at IS NULL", episodeID, projectID). Take(&episode).Error; err != nil { return videoarchive.Manifest{}, err } rows := make([]episodeVideoArchiveRow, 0) query := s.DB.Table("generation_outputs output"). Select(`media.object_key,media.mime_type,storyboard.sequence_no, storyboard.active_output_id=output.id AS active`). Joins("JOIN generation_tasks task ON task.id=output.task_id AND task.task_type='video_generation'"). Joins("JOIN episode_storyboards storyboard ON storyboard.id=task.storyboard_id AND storyboard.deleted_at IS NULL"). Joins("JOIN media_assets media ON media.id=output.media_asset_id AND media.deleted_at IS NULL"). Where("storyboard.episode_id=?", episodeID) if project.ProjectType == "premium_drama" { query = query.Where("storyboard.active_output_id=output.id OR coalesce(output.metadata->>'candidate','false')='true'") } if err := query.Order("storyboard.sequence_no ASC,(storyboard.active_output_id=output.id) DESC,output.created_at DESC").Find(&rows).Error; err != nil { return videoarchive.Manifest{}, err } if len(rows) == 0 { return videoarchive.Manifest{}, errors.New("当前剧集暂无可下载视频") } entries := make([]videoarchive.Entry, 0, len(rows)) versions := make(map[int]int) for _, row := range rows { label := "主视频" if !row.Active { versions[row.SequenceNo]++ if project.ProjectType == "premium_drama" { label = fmt.Sprintf("备选视频%d", versions[row.SequenceNo]) } else { label = fmt.Sprintf("历史视频%d", versions[row.SequenceNo]) } } name := fmt.Sprintf("分镜%03d-%s%s", row.SequenceNo, label, videoArchiveExtension(row.ObjectKey, row.MimeType)) entries = append(entries, videoarchive.Entry{ObjectKey: row.ObjectKey, Name: videoarchive.SafeFilename(name, "视频.mp4")}) } archiveName := videoarchive.SafeFilename(episode.ProjectName+episode.EpisodeName, "剧集视频") + ".zip" return videoarchive.Manifest{Name: archiveName, Entries: entries}, nil } // videoArchiveExtension 根据已保存的媒体类型和对象键生成稳定的视频扩展名。 func videoArchiveExtension(objectKey, mimeType string) string { switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) { case "video/webm": return ".webm" case "video/quicktime": return ".mov" case "video/x-m4v": return ".m4v" case "video/x-matroska": return ".mkv" case "video/x-msvideo": return ".avi" case "video/mp4": return ".mp4" } extension := strings.ToLower(path.Ext(objectKey)) if map[string]bool{".mp4": true, ".webm": true, ".mov": true, ".m4v": true, ".mkv": true, ".avi": true}[extension] { return extension } return ".mp4" }