1150 lines
29 KiB
Go
1150 lines
29 KiB
Go
// 创作业务接口处理器,负责项目、剧集、资产、分镜和媒体请求的参数与响应处理。
|
|
package handler
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
mediakey "juhe-factory/api/internal/media"
|
|
"juhe-factory/api/internal/model"
|
|
"juhe-factory/api/internal/modules/prompt"
|
|
"juhe-factory/api/internal/service"
|
|
"juhe-factory/api/internal/storage"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type Creative struct {
|
|
service *service.Creative
|
|
prompts *prompt.Service
|
|
cos *storage.COS
|
|
}
|
|
|
|
func NewCreative(service *service.Creative, prompts *prompt.Service, cos *storage.COS) *Creative {
|
|
return &Creative{service: service, prompts: prompts, cos: cos}
|
|
}
|
|
|
|
func (h *Creative) ListProjects(c *gin.Context) {
|
|
items, err := h.service.ListProjects(currentWebUser(c).ID, c.Query("project_type"), c.Query("keyword"))
|
|
if err != nil {
|
|
h.internal(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
|
}
|
|
|
|
func (h *Creative) CreateProject(c *gin.Context) {
|
|
var input service.ProjectInput
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
project, err := h.service.CreateProject(currentWebUser(c).ID, input)
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"data": project})
|
|
}
|
|
|
|
func (h *Creative) GetProject(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
project, err := h.service.GetProject(currentWebUser(c).ID, projectID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": project})
|
|
}
|
|
|
|
func (h *Creative) UpdateProject(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input service.ProjectInput
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
if err := h.service.UpdateProject(currentWebUser(c).ID, projectID, input); err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) DeleteProject(c *gin.Context) {
|
|
if !h.objectStorageAvailable(c) {
|
|
return
|
|
}
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
objectKeys, err := h.service.DeleteProject(currentWebUser(c).ID, projectID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
if !h.deleteStoredObjects(c, objectKeys) {
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) SaveModelConfigs(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var values map[string]any
|
|
if err := c.ShouldBindJSON(&values); err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
if err := h.service.SaveModelConfigs(currentWebUser(c).ID, projectID, values); err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) ListUserModelPreferences(c *gin.Context) {
|
|
items, err := h.service.ListUserModelPreferences(currentWebUser(c).ID, c.Param("scope"))
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
|
}
|
|
|
|
func (h *Creative) SaveUserModelPreference(c *gin.Context) {
|
|
var input struct {
|
|
ModelID string `json:"model_id"`
|
|
}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
if err := h.service.SaveUserModelPreference(currentWebUser(c).ID, c.Param("scope"), c.Param("model_type"), input.ModelID); err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// ListUserPrompts 返回指定类型的可用提示词及当前用户选择。
|
|
func (h *Creative) ListUserPrompts(c *gin.Context) {
|
|
result, err := h.prompts.ListUserPrompts(currentWebUser(c).ID, c.Param("prompt_type"))
|
|
if err != nil {
|
|
h.internal(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"prompts": result.Prompts, "selected_id": result.SelectedID}})
|
|
}
|
|
|
|
// SelectUserPrompt 更新当前用户对指定提示词类型的选择。
|
|
func (h *Creative) SelectUserPrompt(c *gin.Context) {
|
|
userID := currentWebUser(c).ID
|
|
promptType := c.Param("prompt_type")
|
|
var body struct {
|
|
PromptID *uuid.UUID `json:"prompt_id"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
h.bad(c, errors.New("prompt_id无效"))
|
|
return
|
|
}
|
|
if err := h.prompts.SelectUserPrompt(userID, promptType, body.PromptID); err != nil {
|
|
if prompt.IsPersistenceError(err) {
|
|
h.internal(c, err)
|
|
return
|
|
}
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
if body.PromptID == nil {
|
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"status": "disabled"}})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"status": "ok"}})
|
|
}
|
|
|
|
// CreateCustomPrompt 创建当前用户拥有的自定义提示词。
|
|
func (h *Creative) CreateCustomPrompt(c *gin.Context) {
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Content string `json:"content"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
item, err := h.prompts.CreateCustomPrompt(currentWebUser(c).ID, prompt.CustomPromptInput{Name: body.Name, Type: body.Type, Content: body.Content})
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"data": item})
|
|
}
|
|
|
|
// UpdateCustomPrompt 更新当前用户拥有的自定义提示词。
|
|
func (h *Creative) UpdateCustomPrompt(c *gin.Context) {
|
|
userID := currentWebUser(c).ID
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Content string `json:"content"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
item, err := h.prompts.UpdateCustomPrompt(userID, id, prompt.CustomPromptInput{Name: body.Name, Type: body.Type, Content: body.Content})
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": item})
|
|
}
|
|
|
|
// DeleteCustomPrompt 删除当前用户拥有的自定义提示词及其选择关系。
|
|
func (h *Creative) DeleteCustomPrompt(c *gin.Context) {
|
|
userID := currentWebUser(c).ID
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
if err := h.prompts.DeleteCustomPrompt(userID, id); err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) CreateEpisode(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input service.EpisodeInput
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
episode, err := h.service.CreateEpisode(currentWebUser(c).ID, projectID, input)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"data": episode})
|
|
}
|
|
|
|
func (h *Creative) UpdateEpisodeName(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
episode, err := h.service.UpdateEpisodeName(currentWebUser(c).ID, projectID, episodeID, body.Name)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": episode})
|
|
}
|
|
|
|
func (h *Creative) DeleteEpisode(c *gin.Context) {
|
|
if !h.objectStorageAvailable(c) {
|
|
return
|
|
}
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
objectKeys, err := h.service.DeleteEpisode(currentWebUser(c).ID, projectID, episodeID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
if !h.deleteStoredObjects(c, objectKeys) {
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) GetWorkbench(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
data, err := h.service.GetWorkbench(currentWebUser(c).ID, projectID, episodeID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": data})
|
|
}
|
|
|
|
func (h *Creative) CreateAsset(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
AssetType string `json:"asset_type"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
asset, err := h.service.CreateAsset(currentWebUser(c).ID, projectID, body.AssetType, body.Name)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"data": asset})
|
|
}
|
|
|
|
func (h *Creative) UpdateAsset(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
assetID, err := service.ParseUUID(c.Param("asset_id"), "资产")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
var body map[string]any
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
if err := h.service.UpdateAsset(currentWebUser(c).ID, projectID, assetID, body); err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) DeleteAsset(c *gin.Context) {
|
|
if !h.objectStorageAvailable(c) {
|
|
return
|
|
}
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
assetID, err := service.ParseUUID(c.Param("asset_id"), "璧勪骇")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
objectKeys, err := h.service.DeleteAsset(currentWebUser(c).ID, projectID, assetID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
if !h.deleteStoredObjects(c, objectKeys) {
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) UpdateStoryboard(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
var body map[string]any
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
if err := h.service.UpdateStoryboard(currentWebUser(c).ID, projectID, storyboardID, body); err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) InsertStoryboard(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
var body struct {
|
|
Position string `json:"position"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
storyboard, err := h.service.InsertStoryboard(currentWebUser(c).ID, projectID, storyboardID, body.Position)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"data": storyboard})
|
|
}
|
|
|
|
func (h *Creative) DeleteStoryboard(c *gin.Context) {
|
|
if !h.objectStorageAvailable(c) {
|
|
return
|
|
}
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
objectKeys, err := h.service.DeleteStoryboard(currentWebUser(c).ID, projectID, storyboardID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
if !h.deleteStoredObjects(c, objectKeys) {
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) QueueGeneration(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
var body struct {
|
|
TaskType string `json:"task_type"`
|
|
Input map[string]any `json:"input"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
task, err := h.service.QueueGeneration(currentWebUser(c).ID, projectID, &episodeID, storyboardID, body.TaskType, body.Input)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
|
}
|
|
|
|
func (h *Creative) QueueAnalysis(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
task, err := h.service.QueueAnalysis(currentWebUser(c).ID, projectID, episodeID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
|
}
|
|
|
|
func (h *Creative) Reanalyze(c *gin.Context) {
|
|
if !h.objectStorageAvailable(c) {
|
|
return
|
|
}
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
objectKeys, err := h.service.ResetEpisodeAnalysis(currentWebUser(c).ID, projectID, episodeID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
if !h.deleteStoredObjects(c, objectKeys) {
|
|
return
|
|
}
|
|
task, err := h.service.QueueAnalysis(currentWebUser(c).ID, projectID, episodeID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
|
}
|
|
|
|
func (h *Creative) QueueStoryboardAnalysis(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
task, err := h.service.QueueStoryboardAnalysis(currentWebUser(c).ID, projectID, episodeID, storyboardID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
|
}
|
|
|
|
func (h *Creative) UpdateEpisodeAnalysisSettings(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
var body struct {
|
|
AudioSource string `json:"audio_source"`
|
|
SourceLanguage string `json:"source_language"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
if err := h.service.UpdateEpisodeAnalysisSettings(currentWebUser(c).ID, projectID, episodeID, body.AudioSource, body.SourceLanguage); err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) ListOptions(c *gin.Context) {
|
|
data, err := h.service.ListOptions()
|
|
if err != nil {
|
|
h.internal(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": data})
|
|
}
|
|
|
|
func (h *Creative) ListTasks(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var episodeID *uuid.UUID
|
|
if raw := strings.TrimSpace(c.Query("episode_id")); raw != "" {
|
|
parsed, err := service.ParseUUID(raw, "剧集")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
episodeID = &parsed
|
|
}
|
|
items, err := h.service.ListTasks(currentWebUser(c).ID, projectID, episodeID)
|
|
if err != nil {
|
|
h.internal(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
|
}
|
|
|
|
func (h *Creative) CancelTask(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
taskID, err := service.ParseUUID(c.Param("task_id"), "任务")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
if err := h.service.CancelTask(currentWebUser(c).ID, projectID, taskID); err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) ListStoryboardOutputs(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
items, err := h.service.ListStoryboardOutputs(currentWebUser(c).ID, projectID, storyboardID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
|
}
|
|
|
|
// RemoveStoryboardImage 将当前分镜图移入历史记录并清空主图引用。
|
|
func (h *Creative) RemoveStoryboardImage(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
if err := h.service.RemoveStoryboardImage(currentWebUser(c).ID, projectID, storyboardID); err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) ActivateStoryboardOutput(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
outputID, err := service.ParseUUID(c.Param("output_id"), "生成结果")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
if err := h.service.ActivateStoryboardOutput(currentWebUser(c).ID, projectID, storyboardID, outputID); err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) AddStoryboardCandidate(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
outputID, err := service.ParseUUID(c.Param("output_id"), "生成结果")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
if err := h.service.AddStoryboardCandidate(currentWebUser(c).ID, projectID, storyboardID, outputID); err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) RemoveStoryboardCandidate(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
outputID, err := service.ParseUUID(c.Param("output_id"), "生成结果")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
if err := h.service.RemoveStoryboardCandidate(currentWebUser(c).ID, projectID, storyboardID, outputID); err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) DeleteStoryboardOutput(c *gin.Context) {
|
|
if !h.objectStorageAvailable(c) {
|
|
return
|
|
}
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
outputID, err := service.ParseUUID(c.Param("output_id"), "历史记录")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
objectKey, err := h.service.DeleteStoryboardOutput(currentWebUser(c).ID, projectID, storyboardID, outputID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
if !h.deleteStoredObjects(c, []string{objectKey}) {
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) ListAssetOutputs(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
assetID, err := service.ParseUUID(c.Param("asset_id"), "璧勪骇")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
items, err := h.service.ListAssetOutputs(currentWebUser(c).ID, projectID, assetID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
|
}
|
|
|
|
func (h *Creative) ActivateAssetOutput(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
assetID, err := service.ParseUUID(c.Param("asset_id"), "璧勪骇")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
outputID, err := service.ParseUUID(c.Param("output_id"), "鐢熸垚缁撴灉")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
if err := h.service.ActivateAssetOutput(currentWebUser(c).ID, projectID, assetID, outputID); err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) DeleteAssetOutput(c *gin.Context) {
|
|
if !h.objectStorageAvailable(c) {
|
|
return
|
|
}
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
assetID, err := service.ParseUUID(c.Param("asset_id"), "璧勪骇")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
outputID, err := service.ParseUUID(c.Param("output_id"), "鍘嗗彶璁板綍")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
objectKey, err := h.service.DeleteAssetOutput(currentWebUser(c).ID, projectID, assetID, outputID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
if !h.deleteStoredObjects(c, []string{objectKey}) {
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) UploadEpisodeSource(c *gin.Context) {
|
|
h.uploadEpisodeMedia(c, "source")
|
|
}
|
|
|
|
func (h *Creative) UploadEpisodeSubtitle(c *gin.Context) {
|
|
h.uploadEpisodeMedia(c, "subtitle")
|
|
}
|
|
|
|
func (h *Creative) uploadEpisodeMedia(c *gin.Context, kind string) {
|
|
if h.cos == nil {
|
|
fail(c, http.StatusServiceUnavailable, "cos_not_configured", "COS 对象存储未配置")
|
|
return
|
|
}
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
target, err := h.service.PrepareEpisodeMediaUpload(currentWebUser(c).ID, projectID, episodeID, kind)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
episode := target.Episode
|
|
file, header, err := c.Request.FormFile("file")
|
|
if err != nil {
|
|
h.bad(c, errors.New("请选择文件"))
|
|
return
|
|
}
|
|
defer file.Close()
|
|
contentType := cleanContentType(header.Header.Get("Content-Type"))
|
|
maxBytes := int64(50 * 1024 * 1024)
|
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
|
allowedVideoTypes := map[string]bool{"video/mp4": true, "video/webm": true, "video/quicktime": true, "video/x-m4v": true}
|
|
allowed := allowedVideoTypes[contentType] && map[string]bool{".mp4": true, ".webm": true, ".mov": true, ".m4v": true}[ext]
|
|
if kind == "subtitle" {
|
|
maxBytes = 2 * 1024 * 1024
|
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
|
allowed = ext == ".srt" || ext == ".vtt" || ext == ".txt"
|
|
if contentType == "" {
|
|
contentType = "text/plain"
|
|
}
|
|
}
|
|
if !allowed || header.Size <= 0 || header.Size > maxBytes {
|
|
h.bad(c, fmt.Errorf("%s文件格式或大小不符合要求", map[bool]string{true: "字幕", false: "视频"}[kind == "subtitle"]))
|
|
return
|
|
}
|
|
oldMediaID := target.OldMediaID
|
|
if kind == "source" && episode.SourceVideoAssetID != nil {
|
|
objectKeys, err := h.service.ResetEpisodeAnalysis(currentWebUser(c).ID, projectID, episodeID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
if !h.deleteStoredObjects(c, objectKeys) {
|
|
return
|
|
}
|
|
}
|
|
mediaID := uuid.New()
|
|
key := mediakey.EpisodeSource(projectID, episode.EpisodeNo, episodeID, mediaID, header.Filename, contentType)
|
|
displayName := fmt.Sprintf("第%03d集原视频", episode.EpisodeNo)
|
|
status := "uploaded"
|
|
if kind == "subtitle" {
|
|
key = mediakey.EpisodeSubtitle(projectID, episode.EpisodeNo, episodeID, mediaID, header.Filename, contentType)
|
|
displayName = fmt.Sprintf("第%03d集字幕", episode.EpisodeNo)
|
|
status = episode.Status
|
|
}
|
|
asset, err := h.storeUpload(c, mediaID, currentWebUser(c).ID, file, header, key, displayName, contentType, nil)
|
|
if err != nil {
|
|
h.internal(c, err)
|
|
return
|
|
}
|
|
if err := h.service.PersistEpisodeMediaUpload(episodeID, kind, status, asset, oldMediaID); err != nil {
|
|
_ = h.cos.Delete(c.Request.Context(), asset.ObjectKey)
|
|
h.internal(c, err)
|
|
return
|
|
}
|
|
if target.OldObjectKey != "" && !h.deleteStoredObjects(c, []string{target.OldObjectKey}) {
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"data": asset})
|
|
}
|
|
|
|
func (h *Creative) UploadAssetImage(c *gin.Context) {
|
|
h.uploadAssetMedia(c, false)
|
|
}
|
|
|
|
func (h *Creative) GetMedia(c *gin.Context) {
|
|
if h.cos == nil {
|
|
fail(c, http.StatusServiceUnavailable, "cos_not_configured", "COS 未配置")
|
|
return
|
|
}
|
|
mediaID, err := service.ParseUUID(c.Param("media_id"), "媒体")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
objectKey, err := h.service.MediaObject(currentWebUser(c).ID, mediaID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
body, contentType, size, err := h.cos.Open(c, objectKey)
|
|
if err != nil {
|
|
h.internal(c, err)
|
|
return
|
|
}
|
|
defer body.Close()
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
c.Header("Cache-Control", "private, max-age=3600")
|
|
c.DataFromReader(http.StatusOK, size, contentType, body, nil)
|
|
}
|
|
|
|
func (h *Creative) RemoveAssetImage(c *gin.Context) {
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
assetID, err := service.ParseUUID(c.Param("asset_id"), "资产")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
if err := h.service.RemoveAssetImage(currentWebUser(c).ID, projectID, assetID); err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Creative) UploadAssetAudio(c *gin.Context) {
|
|
h.uploadAssetMedia(c, true)
|
|
}
|
|
|
|
func (h *Creative) uploadAssetMedia(c *gin.Context, audio bool) {
|
|
if h.cos == nil {
|
|
fail(c, http.StatusServiceUnavailable, "cos_not_configured", "COS 对象存储未配置")
|
|
return
|
|
}
|
|
projectID, ok := h.projectID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
assetID, err := service.ParseUUID(c.Param("asset_id"), "资产")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return
|
|
}
|
|
target, err := h.service.PrepareAssetMediaUpload(currentWebUser(c).ID, projectID, assetID)
|
|
if err != nil {
|
|
h.respondError(c, err)
|
|
return
|
|
}
|
|
asset := target.Asset
|
|
file, header, err := c.Request.FormFile("file")
|
|
if err != nil {
|
|
h.bad(c, errors.New("请选择文件"))
|
|
return
|
|
}
|
|
defer file.Close()
|
|
contentType := cleanContentType(header.Header.Get("Content-Type"))
|
|
maxBytes := h.cos.MaxImageBytes()
|
|
allowed := map[string]bool{"image/jpeg": true, "image/png": true, "image/webp": true}[contentType]
|
|
displaySuffix := "资产图"
|
|
if audio {
|
|
maxBytes = h.cos.MaxAudioBytes()
|
|
allowed = strings.HasPrefix(contentType, "audio/")
|
|
displaySuffix = "参考音频"
|
|
}
|
|
if !allowed || header.Size <= 0 || header.Size > maxBytes {
|
|
h.bad(c, errors.New("文件格式或大小不符合要求"))
|
|
return
|
|
}
|
|
mediaID := uuid.New()
|
|
key := mediakey.AssetReference(projectID, asset.AssetType, asset.ID, mediaID, header.Filename, contentType)
|
|
var dimensions *[2]int
|
|
if !audio {
|
|
width, height, err := uploadDimensions(file)
|
|
if err != nil {
|
|
h.bad(c, errors.New("图片内容无法解析"))
|
|
return
|
|
}
|
|
dimensions = &[2]int{width, height}
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
h.internal(c, err)
|
|
return
|
|
}
|
|
}
|
|
stored, err := h.storeUpload(c, mediaID, currentWebUser(c).ID, file, header, key, asset.Name+displaySuffix, contentType, dimensions)
|
|
if err != nil {
|
|
h.internal(c, err)
|
|
return
|
|
}
|
|
if audio {
|
|
if rawDuration := strings.TrimSpace(c.PostForm("duration_ms")); rawDuration != "" {
|
|
durationMS, parseErr := strconv.ParseInt(rawDuration, 10, 64)
|
|
if parseErr != nil || durationMS < 0 {
|
|
_ = h.cos.Delete(c.Request.Context(), stored.ObjectKey)
|
|
h.bad(c, errors.New("音频时长无效"))
|
|
return
|
|
}
|
|
stored.DurationMS = &durationMS
|
|
}
|
|
}
|
|
persist := func() error {
|
|
if !audio {
|
|
return h.service.AttachUploadedAssetImage(currentWebUser(c).ID, projectID, assetID, stored)
|
|
}
|
|
return h.service.AttachUploadedAssetAudio(assetID, stored)
|
|
}
|
|
if err := persist(); err != nil {
|
|
_ = h.cos.Delete(c.Request.Context(), stored.ObjectKey)
|
|
h.internal(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"data": stored})
|
|
}
|
|
|
|
func (h *Creative) storeUpload(c *gin.Context, mediaID, userID uuid.UUID, file multipart.File, header *multipart.FileHeader, key, displayName, contentType string, dimensions *[2]int) (*model.MediaAsset, error) {
|
|
hash := sha256.New()
|
|
if _, err := io.Copy(hash, file); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
return nil, err
|
|
}
|
|
url, err := h.cos.Put(c, key, contentType, file, header.Size)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
asset := &model.MediaAsset{ID: mediaID, OwnerUserID: &userID, StorageProvider: "cos", ObjectKey: key, PublicURL: url, OriginalName: header.Filename, DisplayName: displayName, MimeType: contentType, SizeBytes: header.Size, SHA256: hex.EncodeToString(hash.Sum(nil))}
|
|
if dimensions != nil {
|
|
asset.Width, asset.Height = &dimensions[0], &dimensions[1]
|
|
}
|
|
return asset, nil
|
|
}
|
|
|
|
func uploadDimensions(file multipart.File) (int, int, error) {
|
|
config, _, err := image.DecodeConfig(file)
|
|
return config.Width, config.Height, err
|
|
}
|
|
|
|
func cleanContentType(value string) string {
|
|
return strings.TrimSpace(strings.Split(value, ";")[0])
|
|
}
|
|
|
|
func (h *Creative) objectStorageAvailable(c *gin.Context) bool {
|
|
if h.cos != nil {
|
|
return true
|
|
}
|
|
fail(c, http.StatusServiceUnavailable, "cos_not_configured", "COS 对象存储未配置")
|
|
return false
|
|
}
|
|
|
|
func (h *Creative) deleteStoredObjects(c *gin.Context, objectKeys []string) bool {
|
|
var firstErr error
|
|
seen := make(map[string]struct{}, len(objectKeys))
|
|
for _, key := range objectKeys {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[key]; exists {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
if err := h.cos.Delete(c.Request.Context(), key); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
if firstErr != nil {
|
|
h.internal(c, firstErr)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (h *Creative) projectID(c *gin.Context) (uuid.UUID, bool) {
|
|
id, err := service.ParseUUID(c.Param("project_id"), "项目")
|
|
if err != nil {
|
|
h.bad(c, err)
|
|
return uuid.Nil, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
func (h *Creative) bad(c *gin.Context, err error) {
|
|
fail(c, http.StatusBadRequest, "invalid_request", err.Error())
|
|
}
|
|
|
|
func (h *Creative) internal(c *gin.Context, _ error) {
|
|
fail(c, http.StatusInternalServerError, "internal_error", "操作失败,请稍后重试")
|
|
}
|
|
|
|
func (h *Creative) respondError(c *gin.Context, err error) {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
fail(c, http.StatusNotFound, "not_found", "数据不存在或无权访问")
|
|
return
|
|
}
|
|
h.bad(c, err)
|
|
}
|