Files
ttstd-infinite-canvas/backend/internal/service/service.go

1657 lines
59 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unicode/utf8"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
type Service struct {
repo *repository.Repository
dataDir string
cancelMu sync.Mutex
registrationMu sync.Mutex
emailCodeMu sync.Mutex
redeemBatchMu sync.Mutex
storageMu sync.Mutex
characterTaskMu sync.Mutex
activeCancels map[string]context.CancelFunc
pendingStorage map[string]int64
coordinator *runtimeCoordinator
runtimeErr error
workerID string
}
const taskWorkerConcurrency = 3
const taskLogPayloadLimit = 4000
type CreateSessionRequest struct {
ProjectID string `json:"projectId"`
Prompt string `json:"prompt"`
CanvasSnapshot map[string]any `json:"canvasSnapshot"`
References []string `json:"references"`
Requirements string `json:"requirements"`
CanvasAssets []storyboardAsset `json:"canvasAssets"`
Config providerConfig `json:"config"`
}
type CreateTaskRequest struct {
SessionID string `json:"sessionId"`
ProjectID string `json:"projectId"`
Type string `json:"type"`
Operation string `json:"operation"`
Prompt string `json:"prompt"`
Provider string `json:"provider"`
Model string `json:"model"`
Input map[string]any `json:"input"`
}
type SessionDetail struct {
Session model.Session `json:"session"`
Messages []model.Message `json:"messages"`
Tasks []TaskSummary `json:"tasks"`
Results []model.Result `json:"results"`
}
type TaskSummary struct {
ID string `json:"id"`
SessionID string `json:"sessionId,omitempty"`
ProjectID string `json:"projectId,omitempty"`
Type string `json:"type"`
Status model.TaskStatus `json:"status"`
Stage string `json:"stage"`
Progress int `json:"progress"`
Prompt string `json:"prompt"`
Operation string `json:"operation,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
ProviderRequestID string `json:"providerRequestId,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
PreviewURL string `json:"previewUrl,omitempty"`
PreviewKind string `json:"previewKind,omitempty"`
Attempts int `json:"attempts"`
StartedAt *time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Billing *TaskBillingSummary `json:"billing,omitempty"`
}
type TaskBillingSummary struct {
AmountMicrocredits int64 `json:"amountMicrocredits"`
Status model.BillingStatus `json:"status"`
}
type TaskListOptions struct {
Limit int
ProjectID string
ActiveOnly bool
}
type agentStoryboardInput struct {
References []string `json:"references"`
CanvasSnapshot map[string]any `json:"canvasSnapshot"`
Requirements string `json:"requirements"`
CanvasAssets []storyboardAsset `json:"canvasAssets"`
Config providerConfig `json:"config"`
ShotDuration int `json:"shotDurationSeconds"`
ShotCount int `json:"shotCount"`
}
type storyboardAsset struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Tags []string `json:"tags"`
Prompt string `json:"prompt"`
}
type agentStoryboardPlan struct {
Title string `json:"title"`
Logline string `json:"logline"`
StyleGuide string `json:"styleGuide"`
Characters []string `json:"characters"`
Locations []string `json:"locations"`
Shots []agentStoryboardShot `json:"shots"`
Raw map[string]interface{} `json:"-"`
}
type agentStoryboardShot struct {
Title string `json:"title"`
Description string `json:"description"`
Duration int `json:"durationSeconds"`
Dialogue string `json:"dialogue"`
ShotSize string `json:"shotSize"`
Emotion string `json:"emotion"`
Lighting string `json:"lightingAndAtmosphere"`
AudioEffects string `json:"audioEffects"`
VisualPrompt string `json:"visualPrompt"`
VideoPrompt string `json:"videoPrompt"`
Camera string `json:"camera"`
Motion string `json:"motion"`
TimeBeats string `json:"timeBeats"`
Negative string `json:"negativePrompt"`
AssetTags []string `json:"assetTags"`
}
func New(repo *repository.Repository, dataDir string) *Service {
coordinator, err := newRuntimeCoordinator(repo.Dialect())
return &Service{repo: repo, dataDir: dataDir, activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, workerID: newID()}
}
func (s *Service) StartWorker() {
go func() {
slots := make(chan struct{}, maxChannelConcurrencyLimit)
dispatch := func() {
setting, err := s.runtimeConcurrencySetting()
if err != nil {
return
}
workerConcurrency := setting.WorkerConcurrency
for len(slots) < workerConcurrency {
releaseGlobal, acquired, err := s.coordinator.acquire(context.Background(), "workers", workerConcurrency, 45*time.Minute)
if err != nil || !acquired {
return
}
task, err := s.repo.ClaimNextTask(s.workerID, 45*time.Second)
if err != nil || task == nil {
releaseGlobal()
return
}
slots <- struct{}{}
go func(task *model.Task) {
defer func() { <-slots; releaseGlobal() }()
_ = s.processClaimedTask(task)
}(task)
}
}
dispatch()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
dispatch()
}
}()
}
func (s *Service) CreateSession(userID string, req CreateSessionRequest) (*SessionDetail, error) {
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
return nil, errors.New("prompt is required")
}
compactedSnapshot := compactPersistedValue(req.CanvasSnapshot)
snapshotJSON, _ := json.Marshal(compactedSnapshot)
session := model.Session{ID: newID(), UserID: userID, ProjectID: req.ProjectID, Status: model.SessionStatusActive, Prompt: prompt, CanvasSnapshotJSON: string(snapshotJSON)}
policy, err := s.RuntimePolicy()
if err != nil {
return nil, err
}
s.storageMu.Lock()
usage, err := s.repo.UserStorageUsage(userID)
if err != nil {
s.storageMu.Unlock()
return nil, err
}
incomingBytes := int64(len([]byte(prompt))*2 + len(snapshotJSON))
if err := validateStructuredStorageQuotaWithPolicy(usage, "session", true, incomingBytes, policy.Resource); err != nil {
s.storageMu.Unlock()
return nil, err
}
if err := s.repo.Create(&session); err != nil {
s.storageMu.Unlock()
return nil, err
}
if err := s.repo.Create(&model.Message{ID: newID(), UserID: userID, SessionID: session.ID, Role: "user", Content: prompt}); err != nil {
cleanupErr := s.repo.DeleteSessionDraft(userID, session.ID)
s.storageMu.Unlock()
if cleanupErr != nil {
return nil, fmt.Errorf("创建会话消息失败:%v;清理会话失败:%w", err, cleanupErr)
}
return nil, err
}
s.storageMu.Unlock()
taskReq := CreateTaskRequest{SessionID: session.ID, ProjectID: req.ProjectID, Type: "agent_storyboard", Operation: "storyboard", Prompt: prompt, Provider: "openai-compatible", Model: req.Config.Model, Input: map[string]any{"references": req.References, "canvasSnapshot": compactedSnapshot, "requirements": req.Requirements, "canvasAssets": req.CanvasAssets, "config": req.Config}}
if _, err := s.CreateTask(userID, taskReq); err != nil {
s.storageMu.Lock()
cleanupErr := s.repo.DeleteSessionDraft(userID, session.ID)
s.storageMu.Unlock()
if cleanupErr != nil {
return nil, fmt.Errorf("创建会话任务失败:%v;清理会话失败:%w", err, cleanupErr)
}
return nil, err
}
s.recordActivity(userID, "agent_message", 1)
return s.SessionDetail(userID, session.ID)
}
func channelModelNames(channel model.ModelChannel) []string {
models := []string{}
_ = json.Unmarshal([]byte(channel.ModelsJSON), &models)
return uniqueNonEmpty(models)
}
func (s *Service) SessionDetail(userID string, id string) (*SessionDetail, error) {
session, err := s.repo.SessionForUser(userID, id)
if err != nil {
return nil, err
}
messages, err := s.repo.SessionMessages(userID, id)
if err != nil {
return nil, err
}
tasks, err := s.repo.SessionTasks(userID, id)
if err != nil {
return nil, err
}
taskSummaries := taskSummariesForOutput(tasks)
results, err := s.repo.SessionResults(userID, id)
if err != nil {
return nil, err
}
return &SessionDetail{Session: *session, Messages: messages, Tasks: taskSummaries, Results: results}, nil
}
func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, error) {
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
return nil, errors.New("prompt is required")
}
normalizedInput, err := normalizeTaskInput(req.Input)
if err != nil {
return nil, err
}
if containsInlineMediaDataURL(normalizedInput) {
return nil, BadAuthRequest("任务输入不能包含内嵌媒体,请先上传到资源存储")
}
policy, err := s.RuntimePolicy()
if err != nil {
return nil, err
}
activeTasks, err := s.repo.ActiveTaskCountForUser(userID)
if err != nil {
return nil, err
}
if activeTasks >= int64(policy.Task.ActiveTaskLimit) {
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
}
taskType := req.Type
if taskType == "" {
taskType = "video_image_to_video"
}
task := model.Task{ID: newID(), UserID: userID, SessionID: req.SessionID, ProjectID: req.ProjectID, Type: taskType, Status: model.TaskStatusQueued, Stage: "等待队列调度", Progress: 5, Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: req.Model}
if err := s.ensureTaskProjectActive(userID, req.ProjectID); err != nil {
return nil, err
}
billingOrder, err := s.taskBillingOrder(userID, &task, normalizedInput)
if err != nil {
return nil, err
}
if err := s.protectTaskSecrets(normalizedInput); err != nil {
return nil, err
}
inputJSON, _ := json.Marshal(normalizedInput)
task.InputJSON = string(inputJSON)
if billingOrder != nil {
task.BillingOrderID = billingOrder.ID
}
err = s.createTaskWithinStorageQuota(&task, billingOrder, policy)
if errors.Is(err, repository.ErrActiveTaskLimit) {
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
}
if errors.Is(err, repository.ErrInsufficientCredits) {
return nil, BadAuthRequest("积分不足,请先使用兑换码充值")
}
if err != nil {
return nil, err
}
s.recordActivity(userID, "task", 1)
_ = s.log(userID, task.ID, "info", "任务已进入队列", "")
return taskForOutput(task), nil
}
// 所有任务输入先收敛为 JSON 对象,确保计费与密钥保护不会因 Go 结构体类型不同而被绕过。
func normalizeTaskInput(input map[string]any) (map[string]any, error) {
if input == nil {
return map[string]any{}, nil
}
encoded, err := json.Marshal(input)
if err != nil {
return nil, BadAuthRequest("任务输入格式无效")
}
var normalized map[string]any
if err := json.Unmarshal(encoded, &normalized); err != nil {
return nil, BadAuthRequest("任务输入格式无效")
}
if snapshot, ok := normalized["canvasSnapshot"]; ok {
normalized["canvasSnapshot"] = compactPersistedValue(snapshot)
}
return normalized, nil
}
func compactPersistedValue(value interface{}) interface{} {
switch item := value.(type) {
case map[string]interface{}:
result := make(map[string]interface{}, len(item))
for key, child := range item {
if text, ok := child.(string); ok && strings.HasPrefix(text, "data:") {
result[key] = ""
continue
}
result[key] = compactPersistedValue(child)
}
return result
case []interface{}:
result := make([]interface{}, len(item))
for index, child := range item {
result[index] = compactPersistedValue(child)
}
return result
default:
return value
}
}
func (s *Service) Tasks(userID string, limit int) ([]TaskSummary, error) {
return s.TasksWithOptions(userID, TaskListOptions{Limit: limit})
}
func (s *Service) TasksWithOptions(userID string, options TaskListOptions) ([]TaskSummary, error) {
tasks, err := s.repo.Tasks(userID, options.Limit, options.ProjectID, options.ActiveOnly)
if err != nil {
return nil, err
}
orders, err := s.repo.BillingOrdersByTaskIDs(userID, taskBillingTaskIDs(tasks))
if err != nil {
return nil, err
}
return taskSummariesForOutputWithBilling(tasks, orders), nil
}
func (s *Service) Task(userID string, id string) (*model.Task, error) {
task, err := s.repo.TaskForUser(userID, id)
if err != nil {
return nil, err
}
s.hydrateTaskProviderRequestID(task)
return taskForOutput(*task), nil
}
func (s *Service) hydrateTaskProviderRequestID(task *model.Task) {
if task == nil || task.ProviderRequestID != "" {
return
}
if task.BillingOrderID != "" {
if order, err := s.repo.BillingOrder(task.BillingOrderID); err == nil {
task.ProviderRequestID = strings.TrimSpace(order.ProviderRequestID)
}
}
if task.ProviderRequestID == "" {
if providerRequestID, err := s.repo.LatestProviderRequestIDForTask(task.ID); err == nil {
task.ProviderRequestID = providerRequestID
}
}
}
// 上游请求日志会在任务执行期间更新 provider 状态,终态保存前必须重新合并,避免旧任务对象覆盖可恢复 ID。
func (s *Service) refreshTaskProviderState(task *model.Task) error {
if task == nil || task.ID == "" {
return errors.New("任务状态无效")
}
latest, err := s.repo.Task(task.ID)
if err != nil {
return fmt.Errorf("刷新任务上游状态失败:%w", err)
}
if latest.ProviderRequestID != "" {
task.ProviderRequestID = latest.ProviderRequestID
}
task.PollStage = latest.PollStage
task.NextPollAt = latest.NextPollAt
return nil
}
func (s *Service) RetryTask(userID string, id string) (*model.Task, error) {
task, err := s.repo.TaskForUser(userID, id)
if err != nil {
return nil, err
}
if task.Status != model.TaskStatusFailed && task.Status != model.TaskStatusCancelled {
return nil, errors.New("only failed or cancelled tasks can be retried")
}
if isContentModerationFailure(task.Error) {
return nil, BadAuthRequest(contentModerationRetryMessage)
}
decryptedInput, err := s.decryptTaskInputJSON(task.InputJSON)
if err != nil {
return nil, err
}
var billingInput map[string]any
if err := json.Unmarshal([]byte(decryptedInput), &billingInput); err != nil {
return nil, err
}
billingOrder, err := s.taskBillingOrder(userID, task, billingInput)
if err != nil {
return nil, err
}
policy, err := s.RuntimePolicy()
if err != nil {
return nil, err
}
if err := s.ensureTaskProjectActive(userID, task.ProjectID); err != nil {
return nil, err
}
task, err = s.repo.RetryTaskWithBilling(userID, task.ID, billingOrder, policy.Task.ActiveTaskLimit)
if errors.Is(err, repository.ErrInsufficientCredits) {
return nil, BadAuthRequest("积分不足,请先使用兑换码充值")
}
if errors.Is(err, repository.ErrActiveTaskLimit) {
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
}
if errors.Is(err, repository.ErrTaskNotRetryable) {
return nil, BadAuthRequest("任务已被其他请求重新入队,请勿重复重试")
}
if err != nil {
return nil, err
}
if task.SessionID != "" {
if session, err := s.repo.SessionForUser(task.UserID, task.SessionID); err == nil {
session.Status = model.SessionStatusActive
session.CanvasOpsJSON = ""
_ = s.repo.Save(session)
}
}
_ = s.log(userID, task.ID, "info", "任务已重新入队", "")
return taskForOutput(*task), nil
}
func (s *Service) CancelTask(userID string, id string) (*model.Task, error) {
task, err := s.repo.TaskForUser(userID, id)
if err != nil {
return nil, err
}
if task.Status == model.TaskStatusSucceeded {
return nil, errors.New("completed task cannot be cancelled")
}
now := time.Now()
if task.Status == model.TaskStatusQueued {
cancelled, err := s.repo.CancelTaskIfStatus(userID, task.ID, model.TaskStatusQueued, now)
if err != nil {
return nil, err
}
if cancelled {
if err := s.RefundBilling(task.BillingOrderID, "任务在调用上游前取消"); err != nil {
return nil, err
}
task, err = s.repo.TaskForUser(userID, id)
if err != nil {
return nil, err
}
} else {
task, err = s.repo.TaskForUser(userID, id)
if err != nil {
return nil, err
}
}
}
if task.Status == model.TaskStatusRunning {
s.cancelActiveTask(task.ID)
cancelled, err := s.repo.CancelTaskIfStatus(userID, task.ID, model.TaskStatusRunning, now)
if err != nil {
return nil, err
}
if !cancelled {
latest, latestErr := s.repo.TaskForUser(userID, id)
if latestErr != nil {
return nil, latestErr
}
if latest.Status == model.TaskStatusSucceeded {
return nil, errors.New("completed task cannot be cancelled")
}
task = latest
} else {
if err := s.MarkBillingUncertain(task.BillingOrderID, "运行中的上游请求被用户取消,费用状态待核对"); err != nil {
return nil, err
}
task, err = s.repo.TaskForUser(userID, id)
if err != nil {
return nil, err
}
}
}
if task.Status != model.TaskStatusCancelled {
return nil, errors.New("task cannot be cancelled in its current state")
}
if task.SessionID != "" {
_ = s.markSessionFailed(*task, "会话任务已取消。")
}
_ = s.log(userID, task.ID, "warn", "任务已取消", "")
return taskForOutput(*task), nil
}
func (s *Service) TaskLogs(userID string, id string) ([]model.TaskLog, error) {
return s.repo.TaskLogs(userID, id)
}
func taskSummariesForOutput(tasks []model.Task) []TaskSummary {
return taskSummariesForOutputWithBilling(tasks, nil)
}
func taskSummariesForOutputWithBilling(tasks []model.Task, orders map[string]model.BillingOrder) []TaskSummary {
result := make([]TaskSummary, 0, len(tasks))
for _, task := range tasks {
summary := taskSummaryForOutput(task)
if order, ok := orders[task.ID]; ok {
summary.Billing = &TaskBillingSummary{AmountMicrocredits: order.AmountMicrocredits, Status: order.Status}
if summary.ProviderRequestID == "" {
summary.ProviderRequestID = order.ProviderRequestID
}
}
result = append(result, summary)
}
return result
}
func taskBillingTaskIDs(tasks []model.Task) []string {
ids := make([]string, 0, len(tasks))
seen := map[string]struct{}{}
for _, task := range tasks {
if task.BillingOrderID == "" {
continue
}
if _, ok := seen[task.ID]; ok {
continue
}
seen[task.ID] = struct{}{}
ids = append(ids, task.ID)
}
return ids
}
func taskSummaryForOutput(task model.Task) TaskSummary {
errorCode := ""
if isContentModerationFailure(task.Error) {
errorCode = contentModerationErrorCode
}
previewURL, previewKind := taskMediaPreview(task.ResultJSON, task.Type)
return TaskSummary{
ID: task.ID,
SessionID: task.SessionID,
ProjectID: task.ProjectID,
Type: task.Type,
Status: task.Status,
Stage: task.Stage,
Progress: task.Progress,
Prompt: truncateRunes(task.Prompt, 500),
Operation: task.Operation,
Provider: task.Provider,
Model: task.Model,
ProviderRequestID: task.ProviderRequestID,
ErrorCode: errorCode,
PreviewURL: previewURL,
PreviewKind: previewKind,
Attempts: task.Attempts,
StartedAt: task.StartedAt,
CompletedAt: task.CompletedAt,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
}
}
// 列表只暴露首个可访问媒体地址,避免把完整生成结果和内嵌数据带回前端。
func taskMediaPreview(raw string, taskType string) (string, string) {
if strings.TrimSpace(raw) == "" {
return "", ""
}
var payload any
if json.Unmarshal([]byte(raw), &payload) != nil {
return "", ""
}
defaultKind := "image"
if strings.Contains(strings.ToLower(taskType), "video") {
defaultKind = "video"
}
return findTaskMediaPreview(payload, defaultKind)
}
func findTaskMediaPreview(value any, hint string) (string, string) {
switch item := value.(type) {
case string:
text := strings.TrimSpace(item)
if !strings.HasPrefix(text, "/api/resources/") && !strings.HasPrefix(text, "http://") && !strings.HasPrefix(text, "https://") {
return "", ""
}
kind := hint
lower := strings.ToLower(text)
if strings.Contains(lower, ".mp4") || strings.Contains(lower, ".webm") || strings.Contains(lower, ".mov") {
kind = "video"
} else if kind != "video" {
kind = "image"
}
return text, kind
case []any:
for _, child := range item {
if previewURL, previewKind := findTaskMediaPreview(child, hint); previewURL != "" {
return previewURL, previewKind
}
}
case map[string]any:
for _, key := range []string{"images", "image", "video", "dataUrl", "url", "resultUrl", "outputUrl"} {
child, exists := item[key]
if !exists {
continue
}
childHint := hint
if key == "video" {
childHint = "video"
} else if key == "images" || key == "image" {
childHint = "image"
}
if previewURL, previewKind := findTaskMediaPreview(child, childHint); previewURL != "" {
return previewURL, previewKind
}
}
}
return "", ""
}
func truncateRunes(value string, limit int) string {
text := []rune(value)
if len(text) <= limit {
return value
}
return string(text[:limit]) + "..."
}
func taskForOutput(task model.Task) *model.Task {
task.InputJSON = publicTaskInputJSON(task.InputJSON)
return &task
}
func publicTaskInputJSON(raw string) string {
if strings.TrimSpace(raw) == "" {
return ""
}
var input map[string]any
if err := json.Unmarshal([]byte(raw), &input); err != nil {
return ""
}
public := map[string]any{}
// 任务完成后仍需依靠这些非敏感 ID 恢复项目产物归属;密钥等配置继续被过滤。
for _, key := range []string{"mode", "metadata", "workflowStepId", "domainProjectId", "assetVersionId", "resourceId", "mediaType", "role"} {
if value, ok := input[key]; ok {
public[key] = value
}
}
if len(public) == 0 {
return ""
}
data, _ := json.Marshal(public)
return string(data)
}
func (s *Service) StoreUpload(userID string, sessionID string, header *multipart.FileHeader) (*model.SessionFile, error) {
policy, err := s.RuntimePolicy()
if err != nil {
return nil, err
}
maxBytes := megabytes(policy.Resource.SessionUploadMB)
if header == nil || header.Size > maxBytes {
return nil, BadAuthRequest(fmt.Sprintf("会话文件不能超过 %dMB", policy.Resource.SessionUploadMB))
}
day, err := s.reserveSessionUploadQuota(userID, header.Size)
if err != nil {
return nil, err
}
reserved := true
defer func() {
if reserved {
s.releaseUserUploadQuota(userID, day, header.Size)
}
}()
file, err := header.Open()
if err != nil {
return nil, err
}
defer file.Close()
uploadDir := filepath.Join(s.dataDir, "uploads")
if err := os.MkdirAll(uploadDir, 0o750); err != nil {
return nil, err
}
if strings.TrimSpace(sessionID) != "" {
if _, err := s.repo.SessionForUser(userID, sessionID); err != nil {
return nil, err
}
}
storedName := newID() + "-" + filepath.Base(header.Filename)
path := filepath.Join(uploadDir, storedName)
dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o640)
if err != nil {
return nil, err
}
size, err := io.Copy(dst, io.LimitReader(file, maxBytes+1))
closeErr := dst.Close()
if err != nil {
_ = os.Remove(path)
return nil, err
}
if closeErr != nil {
_ = os.Remove(path)
return nil, closeErr
}
if size > maxBytes {
_ = os.Remove(path)
return nil, BadAuthRequest(fmt.Sprintf("会话文件不能超过 %dMB", policy.Resource.SessionUploadMB))
}
item := model.SessionFile{ID: newID(), UserID: userID, SessionID: sessionID, FileName: header.Filename, MimeType: header.Header.Get("Content-Type"), Path: path, Size: size}
if err := s.repo.Create(&item); err != nil {
_ = os.Remove(path)
return nil, err
}
s.commitUserUploadQuota(userID, header.Size)
reserved = false
return &item, nil
}
func (s *Service) ProcessNextTask() error {
task, err := s.repo.ClaimNextTask(s.workerID, 45*time.Second)
if err != nil || task == nil {
return err
}
return s.processClaimedTask(task)
}
func (s *Service) processClaimedTask(task *model.Task) error {
_ = s.log(task.UserID, task.ID, "info", "后端任务开始处理", "")
policy, err := s.RuntimePolicy()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), taskExecutionTimeoutWithPolicy(task.Type, policy.Task))
defer cancel()
leaseDone := make(chan struct{})
leaseLost := make(chan error, 1)
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := s.repo.RenewTaskLease(task.ID, s.workerID, 45*time.Second); err != nil {
leaseLost <- err
cancel()
return
}
case <-leaseDone:
return
}
}
}()
defer close(leaseDone)
s.registerActiveTask(task.ID, cancel)
defer s.unregisterActiveTask(task.ID)
task.Stage = "调用生成模型"
task.Progress = 35
_ = s.repo.UpdateTaskProgress(task.ID, task.Stage, task.Progress)
if err := s.MarkBillingRunning(task.BillingOrderID); err != nil {
task.Status = model.TaskStatusFailed
task.Stage = "计费准备失败"
task.Error = taskFailureMessage(err)
task.CompletedAt = ptr(time.Now())
_ = s.repo.Save(task)
_ = s.RefundBilling(task.BillingOrderID, "计费准备失败,上游请求未发出")
return err
}
result, canvasOps, err := s.processTask(ctx, *task)
if stateErr := s.refreshTaskProviderState(task); stateErr != nil {
return stateErr
}
providerSucceeded := err == nil
if err == nil {
result, err = s.persistGeneratedMediaResult(task.UserID, result)
}
if err == nil {
_, err = s.finalizeCharacterTurnaroundTask(*task, result)
}
if err != nil {
channelSlotFailedBeforeRequest := false
if code, _ := ChannelSlotFailureDetails(err); code != "" {
channelSlotFailedBeforeRequest = true
}
select {
case leaseErr := <-leaseLost:
_ = s.log(task.UserID, task.ID, "warn", "任务租约失效,等待其他 worker 恢复", leaseErr.Error())
return leaseErr
default:
}
if errors.Is(err, context.Canceled) {
task.Status = model.TaskStatusCancelled
task.Stage = "任务已取消"
task.Error = "任务已取消"
task.CompletedAt = ptr(time.Now())
_ = s.repo.Save(task)
if channelSlotFailedBeforeRequest {
_ = s.RefundBilling(task.BillingOrderID, "等待渠道槽位期间取消,上游请求未发出")
} else {
_ = s.MarkBillingUncertain(task.BillingOrderID, "任务取消时上游费用状态不明确")
}
_ = s.markSessionFailed(*task, "会话任务已取消。")
_ = s.log(task.UserID, task.ID, "warn", "任务已取消", "")
return nil
}
if errors.Is(err, context.DeadlineExceeded) {
err = errors.New(taskTimeoutMessage(task.Type))
}
task.Status = model.TaskStatusFailed
task.Stage = "任务失败"
task.Error = taskFailureMessage(err)
task.CompletedAt = ptr(time.Now())
_ = s.repo.Save(task)
if providerSucceeded || (!channelSlotFailedBeforeRequest && s.BillingFailureRequiresReview(task.BillingOrderID, task.ID, err)) {
_ = s.MarkBillingUncertain(task.BillingOrderID, task.Error)
} else {
_ = s.RefundBilling(task.BillingOrderID, task.Error)
}
_ = s.markSessionFailed(*task, task.Error)
_ = s.log(task.UserID, task.ID, "error", "任务处理失败", task.Error)
return err
}
latest, err := s.repo.Task(task.ID)
if err != nil {
return err
}
if latest.Status == model.TaskStatusCancelled {
_ = s.MarkBillingUncertain(task.BillingOrderID, "上游已返回结果,但任务被取消")
_ = s.markSessionFailed(*latest, "会话任务已取消。")
_ = s.log(task.UserID, task.ID, "warn", "任务已取消,丢弃生成结果", "")
return nil
}
resultJSON, _ := json.Marshal(result)
opsJSON, _ := json.Marshal(canvasOps)
task.Stage = "持久化生成结果"
task.Progress = 90
_ = s.repo.UpdateTaskProgress(task.ID, task.Stage, task.Progress)
if err := s.saveTaskCompletionWithinStorageQuota(task, resultJSON, opsJSON, len(canvasOps) > 0); err != nil {
task.Status = model.TaskStatusFailed
task.Stage = "任务结果保存失败"
task.Error = taskFailureMessage(err)
task.CompletedAt = ptr(time.Now())
_ = s.repo.Save(task)
_ = s.MarkBillingUncertain(task.BillingOrderID, "上游已成功但任务结果未保存:"+task.Error)
_ = s.markSessionFailed(*task, task.Error)
_ = s.log(task.UserID, task.ID, "error", "任务结果保存失败", task.Error)
return err
}
if completedTask, fetchErr := s.repo.Task(task.ID); fetchErr == nil {
if registerErr := s.RegisterTaskOutputFromTask(*completedTask); registerErr != nil {
// 任务成功与产物登记分开记账;登记失败保持步骤异常,允许项目页幂等补登记。
_ = s.log(task.UserID, task.ID, "error", "任务成功但项目产物登记失败", registerErr.Error())
}
}
if err := s.SettleBilling(task.BillingOrderID, ""); err != nil {
_ = s.MarkBillingUncertain(task.BillingOrderID, "生成成功但积分结算失败:"+err.Error())
_ = s.log(task.UserID, task.ID, "error", "积分结算失败,已进入待核对", err.Error())
}
_ = s.log(task.UserID, task.ID, "info", "任务完成,结果已持久化", "")
return nil
}
func taskFailureMessage(err error) string {
if err == nil {
return "任务处理失败"
}
return truncateRunes(err.Error(), 2_000)
}
func taskExecutionTimeoutWithPolicy(taskType string, policy RuntimeTaskPolicy) time.Duration {
switch {
case taskType == "agent_storyboard" || taskType == "agent_storyboard_rows":
return time.Duration(policy.StoryboardTimeoutMinutes) * time.Minute
case strings.HasPrefix(taskType, "canvas_video") || strings.HasPrefix(taskType, "video_"):
return time.Duration(policy.VideoTimeoutMinutes) * time.Minute
case strings.HasPrefix(taskType, "canvas_image"):
return time.Duration(policy.ImageTimeoutMinutes) * time.Minute
case strings.HasPrefix(taskType, "canvas_audio"):
return time.Duration(policy.AudioTimeoutMinutes) * time.Minute
case strings.HasPrefix(taskType, "canvas_text"):
return time.Duration(policy.TextTimeoutMinutes) * time.Minute
default:
return time.Duration(policy.DefaultTimeoutMinutes) * time.Minute
}
}
func taskTimeoutMessage(taskType string) string {
if strings.HasPrefix(taskType, "canvas_video") || strings.HasPrefix(taskType, "video_") {
return "视频生成等待超时,请稍后到任务中心查看或重试。"
}
if strings.HasPrefix(taskType, "canvas_image") {
return "图片生成等待超时,请稍后重试。"
}
return "任务执行超时,请稍后重试。"
}
func (s *Service) processTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) {
decryptedInput, err := s.decryptTaskInputJSON(task.InputJSON)
if err != nil {
return nil, nil, err
}
task.InputJSON = decryptedInput
ctx = withProviderAnalytics(ctx, s, task)
if task.Type == "agent_storyboard_rows" {
return s.processStoryboardRowsTask(ctx, task)
}
if strings.HasPrefix(task.Type, "canvas_") || canRunProviderTask(task) {
result, err := s.processCanvasGenerationTask(ctx, task.UserID, task.Type, task.Prompt, task.InputJSON)
return result, nil, err
}
if task.Type == "agent_storyboard" {
return s.processAgentStoryboardTask(ctx, task)
}
if strings.HasPrefix(task.Type, "video_") {
result, ops := buildVideoWorkflowResult(task)
return result, ops, nil
}
result, ops := buildAgentResult(task)
return result, ops, nil
}
func canRunProviderTask(task model.Task) bool {
if !strings.HasPrefix(task.Type, "video_") || strings.TrimSpace(task.InputJSON) == "" {
return false
}
var input map[string]any
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
return false
}
mode, _ := input["mode"].(string)
config, ok := input["config"].(map[string]any)
if mode != "video" || !ok || strings.TrimSpace(fmt.Sprint(config["model"])) == "" {
return false
}
return strings.TrimSpace(fmt.Sprint(config["channelId"])) != "" || (strings.TrimSpace(fmt.Sprint(config["baseUrl"])) != "" && strings.TrimSpace(fmt.Sprint(config["apiKey"])) != "")
}
func (s *Service) processAgentStoryboardTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) {
input := agentStoryboardInput{}
if strings.TrimSpace(task.InputJSON) != "" {
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
return nil, nil, fmt.Errorf("Agent 会话输入解析失败:%w", err)
}
}
assets := input.CanvasAssets
if len(assets) == 0 {
assets = extractStoryboardAssets(input.CanvasSnapshot)
}
plan := fallbackAgentStoryboardPlan(task.Prompt)
if providerConfigReady(input.Config) {
config, err := s.resolveProviderConfig(input.Config)
if err != nil {
return nil, nil, err
}
result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: s.buildAgentStoryboardPlannerPrompt(task.Prompt, input.Requirements, assets, 0, 0), Config: config})
if err != nil {
return nil, nil, err
}
text, _ := result["text"].(string)
nextPlan, err := parseAgentStoryboardPlan(text)
if err != nil {
return nil, nil, err
}
plan = nextPlan
}
return buildAgentStoryboardResult(task, plan, assets)
}
func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) {
input := agentStoryboardInput{}
if strings.TrimSpace(task.InputJSON) != "" {
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
return nil, nil, fmt.Errorf("脚本任务输入解析失败:%w", err)
}
}
if !providerConfigReady(input.Config) {
return nil, nil, errors.New("请先配置可用的文本模型")
}
assets := input.CanvasAssets
if len(assets) == 0 {
assets = extractStoryboardAssets(input.CanvasSnapshot)
}
config, err := s.resolveProviderConfig(input.Config)
if err != nil {
return nil, nil, err
}
result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: s.buildAgentStoryboardPlannerPrompt(task.Prompt, input.Requirements, assets, input.ShotDuration, input.ShotCount), Config: config})
if err != nil {
return nil, nil, err
}
text, _ := result["text"].(string)
plan, err := parseAgentStoryboardPlan(text)
if err == nil {
err = validateStoryboardShotDuration(plan, input.ShotDuration)
}
if err == nil {
err = validateStoryboardShotCount(plan, input.ShotCount)
}
if err != nil {
_ = s.repo.UpdateTaskProgress(task.ID, "修复分镜结构", 55)
repairPrompt := fmt.Sprintf("请修复下面的分镜 JSON。原始校验错误:%s。必须保持原有剧情和镜头内容,补齐缺失字段并修复非法字段值;durationSeconds 必须是 1 到 60 的整数。\n\n%s\n\n只返回完整 JSON,不要 markdown 或解释。\n\n原始输出:\n%s", err.Error(), storyboardCinematicQualityContract(input.ShotDuration, input.ShotCount), text)
repaired, repairErr := runTextTask(withProviderRequestKind(ctx, "repair"), canvasGenerationInput{Mode: "text", Prompt: repairPrompt, Config: config})
if repairErr != nil {
return nil, nil, fmt.Errorf("分镜结构修复失败:%w", repairErr)
}
repairedText, _ := repaired["text"].(string)
plan, err = parseAgentStoryboardPlan(repairedText)
if err == nil {
err = validateStoryboardShotDuration(plan, input.ShotDuration)
}
if err == nil {
err = validateStoryboardShotCount(plan, input.ShotCount)
}
if err != nil {
return nil, nil, fmt.Errorf("分镜模型结构修复后仍不合法:%w", err)
}
}
rows := make([]map[string]any, 0, len(plan.Shots))
for index, shot := range plan.Shots {
matchedAssets := matchStoryboardAssets(assets, shot.AssetTags)
referenceNodeIDs := make([]string, 0, len(matchedAssets))
for _, asset := range matchedAssets {
referenceNodeIDs = append(referenceNodeIDs, asset.ID)
}
rows = append(rows, map[string]any{
"shotNumber": index + 1, "durationSeconds": shot.Duration, "plotDescription": shot.Description,
"dialogue": shot.Dialogue, "characters": []any{}, "shotSize": shot.ShotSize, "emotion": shot.Emotion,
"lightingAndAtmosphere": shot.Lighting, "audioEffects": shot.AudioEffects,
"imageGenerationPrompt": shot.VisualPrompt, "videoMotionPrompt": buildStoryboardVideoPrompt(plan.StyleGuide, shot),
"camera": shot.Camera, "motion": shot.Motion, "timeBeats": shot.TimeBeats, "negativePrompt": shot.Negative,
"referenceNodeIds": referenceNodeIDs, "assetTags": shot.AssetTags,
})
}
return map[string]interface{}{"title": plan.Title, "rows": rows}, nil, nil
}
func providerConfigReady(config providerConfig) bool {
return strings.TrimSpace(config.Model) != "" && (strings.TrimSpace(config.ChannelID) != "" || (strings.TrimSpace(config.BaseURL) != "" && strings.TrimSpace(config.APIKey) != ""))
}
func parseAgentStoryboardPlan(raw string) (agentStoryboardPlan, error) {
jsonText, err := extractJSONText(raw)
if err != nil {
return agentStoryboardPlan{}, err
}
var plan agentStoryboardPlan
if err := json.Unmarshal([]byte(jsonText), &plan); err != nil {
return agentStoryboardPlan{}, fmt.Errorf("分镜 JSON 解析失败:%w", err)
}
plan.Title = defaultString(strings.TrimSpace(plan.Title), "影视分镜")
plan.Logline = defaultString(strings.TrimSpace(plan.Logline), "根据剧情生成的分镜方案")
plan.StyleGuide = defaultString(strings.TrimSpace(plan.StyleGuide), "真实电影机拍摄,保持角色、空间、道具、色彩和镜头语言一致。")
if len(plan.Shots) == 0 {
return agentStoryboardPlan{}, errors.New("分镜模型没有返回 shots")
}
if len(plan.Shots) > 12 {
plan.Shots = plan.Shots[:12]
}
for i := range plan.Shots {
if strings.TrimSpace(plan.Shots[i].Title) == "" {
plan.Shots[i].Title = fmt.Sprintf("镜头 %d", i+1)
}
if strings.TrimSpace(plan.Shots[i].VideoPrompt) == "" {
plan.Shots[i].VideoPrompt = defaultString(plan.Shots[i].VisualPrompt, plan.Shots[i].Description)
}
if strings.TrimSpace(plan.Shots[i].VisualPrompt) == "" {
return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 缺少 visualPrompt", i+1)
}
if strings.TrimSpace(plan.Shots[i].Camera) == "" || strings.TrimSpace(plan.Shots[i].Motion) == "" || strings.TrimSpace(plan.Shots[i].TimeBeats) == "" {
return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 缺少 camera、motion 或 timeBeats", i+1)
}
if plan.Shots[i].Duration <= 0 || plan.Shots[i].Duration > 60 {
return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 的 durationSeconds 必须在 1 到 60 之间", i+1)
}
}
return plan, nil
}
func validateStoryboardShotDuration(plan agentStoryboardPlan, target int) error {
if target == 0 {
return nil
}
if target != 5 && target != 10 && target != 15 && target != 30 {
return fmt.Errorf("不支持的单镜头时长:%d 秒", target)
}
for index, shot := range plan.Shots {
if shot.Duration != target {
return fmt.Errorf("镜头 %d 的时长必须是 %d 秒", index+1, target)
}
}
return nil
}
func validateStoryboardShotCount(plan agentStoryboardPlan, target int) error {
if target == 0 {
return nil
}
if target < 1 || target > 10 {
return fmt.Errorf("分镜数量必须在 1 到 10 之间")
}
if len(plan.Shots) != target {
return fmt.Errorf("分镜数量必须是 %d,实际生成 %d", target, len(plan.Shots))
}
return nil
}
func extractJSONText(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
trimmed = strings.TrimPrefix(trimmed, "```json")
trimmed = strings.TrimPrefix(trimmed, "```")
trimmed = strings.TrimSuffix(trimmed, "```")
start := strings.Index(trimmed, "{")
end := strings.LastIndex(trimmed, "}")
if start < 0 || end < start {
return "", errors.New("分镜模型返回的不是 JSON")
}
return trimmed[start : end+1], nil
}
func fallbackAgentStoryboardPlan(prompt string) agentStoryboardPlan {
title := shortTitle(prompt, 18)
return agentStoryboardPlan{
Title: title,
Logline: "围绕用户 brief 拆解的影视短片工作流。",
StyleGuide: "真实电影机拍摄,自然曝光,低饱和色彩,保持角色、空间、道具和镜头语言一致。",
Characters: []string{"主角:根据 brief 保持服装、动作动机和情绪连续。"},
Locations: []string{"主场景:根据 brief 建立前景、中景、远景和可信光源。"},
Shots: []agentStoryboardShot{
{
Title: "开场建立",
Description: "建立故事空间、主角状态和情绪基调。",
VisualPrompt: "以真实电影机语言建立主要空间和角色状态,前景、中景、远景层次清晰。",
VideoPrompt: "8 秒连续镜头,从故事空间中的人类尺度前景开始,摄影机缓慢前推,先展示环境细节和主角状态,中段让关键冲突迹象进入画面,结尾停在主角反应。自然曝光,真实高光滚降,空气介质和轻微胶片颗粒,避免廉价特效感、均匀平光和无尺度参照。",
Camera: "中景,平视到轻微低机位,中等焦段",
Motion: "缓慢前推,结尾停住",
},
{
Title: "冲突推进",
Description: "推进动作、关系变化和核心冲突。",
VisualPrompt: "主体动作、道具和环境反馈同时出现,空间调度明确。",
VideoPrompt: "10 秒连续镜头,摄影机从主角侧后方跟随移动,开始画面聚焦人物动作和关键道具,中段冲突升级,环境中的灯光、尘埃、水汽或人群反应随动作发生变化,结尾用中近景压住情绪。真实电影机拍摄,受控冷暖对比,运动处保留自然模糊,避免过度锐化、过亮轮廓和塑料表面。",
Camera: "中近景,侧后方跟拍,中长焦压缩空间",
Motion: "跟拍加轻微抬镜",
},
{
Title: "结果与钩子",
Description: "交代结果并留下下一段钩子。",
VisualPrompt: "主角反应、环境后果和悬念信息同框。",
VideoPrompt: "8 秒连续镜头,从冲突后的环境细节开始,摄影机缓慢横移揭示结果,中段主角进入画面并完成关键反应,结尾停在一个可延续的悬念物或空间方向。真实电影摄影质感,暗部保留层次,高光不过曝,低饱和色彩,避免海报式摆拍、干净空白背景和主体完整居中平铺。",
Camera: "中景到近景,横移构图",
Motion: "缓慢横移,结尾定格",
},
},
}
}
func buildAgentStoryboardResult(task model.Task, plan agentStoryboardPlan, assets []storyboardAsset) (map[string]interface{}, []map[string]interface{}, error) {
prefix := "agent-" + task.ID
scriptID := prefix + "-script"
sceneID := prefix + "-scenes"
styleID := prefix + "-style"
referenceID := prefix + "-assets"
finalID := prefix + "-final"
sceneX := 380
styleX := sceneX + 380
ops := []map[string]any{
nodeOpWithMetadata(scriptID, "text", "剧本 · "+shortTitle(plan.Title, 24), 0, 0, map[string]any{"workflowKind": "script", "workflowTitle": "剧本", "status": "success", "content": strings.Join([]string{plan.Title, "", plan.Logline, "", task.Prompt}, "\n")}),
nodeOpWithMetadata(sceneID, "text", "场景设定", sceneX, 0, map[string]any{"workflowKind": "scene", "workflowTitle": "场景", "status": "success", "content": listContent("场景", plan.Locations)}),
nodeOpWithMetadata(styleID, "text", "风格板", styleX, 0, map[string]any{"workflowKind": "styleboard", "workflowTitle": "风格板", "status": "success", "content": plan.StyleGuide}),
nodeOpWithMetadata(referenceID, "text", "参考素材组", 0, 270, map[string]any{"workflowKind": "reference_set", "workflowTitle": "参考素材组", "status": "success", "content": storyboardAssetsContent(assets)}),
nodeOpWithMetadata(finalID, "video", "成片 · 待生成", styleX, 270, map[string]any{"workflowKind": "final", "workflowTitle": "成片", "status": "idle"}),
connectOp(scriptID, sceneID),
}
resultShots := make([]map[string]any, 0, len(plan.Shots))
for index, shot := range plan.Shots {
shotID := fmt.Sprintf("%s-shot-%d", prefix, index+1)
matchedAssets := matchStoryboardAssets(assets, shot.AssetTags)
assetIDs := make([]string, 0, len(matchedAssets))
for _, asset := range matchedAssets {
assetIDs = append(assetIDs, asset.ID)
}
ops = append(ops,
nodeOpWithMetadata(shotID, "video", fmt.Sprintf("镜头 %d · %s", index+1, shortTitle(shot.Title, 18)), index*360, 560, map[string]any{
"workflowKind": "shot",
"workflowTitle": shot.Title,
"workflowDescription": shotDescription(shot),
"shotIndex": index + 1,
"generationMode": "video",
"prompt": buildStoryboardVideoPrompt(plan.StyleGuide, shot),
"composerContent": shotComposerContent(buildStoryboardVideoPrompt(plan.StyleGuide, shot), matchedAssets),
"videoEditOperation": "image_to_video",
"assetTags": shot.AssetTags,
"referenceAssetNodeIds": assetIDs,
"status": "idle",
}),
connectOp(scriptID, shotID),
connectOp(shotID, finalID),
)
for _, asset := range matchedAssets {
ops = append(ops, connectOp(asset.ID, shotID))
}
resultShots = append(resultShots, map[string]any{"title": shot.Title, "description": shot.Description, "assetTags": shot.AssetTags, "referenceAssetNodeIds": assetIDs})
}
ops = append(ops, map[string]any{"type": "select_nodes", "ids": shotIDs(prefix, len(plan.Shots))})
result := map[string]any{
"taskId": task.ID,
"operation": task.Operation,
"provider": defaultString(task.Provider, "internal-agent"),
"model": defaultString(task.Model, "workflow-router"),
"title": plan.Title,
"logline": plan.Logline,
"styleGuide": plan.StyleGuide,
"characters": plan.Characters,
"locations": plan.Locations,
"shots": resultShots,
}
return result, ops, nil
}
func extractStoryboardAssets(snapshot map[string]any) []storyboardAsset {
rawNodes, _ := snapshot["nodes"].([]interface{})
assets := make([]storyboardAsset, 0, len(rawNodes))
for _, raw := range rawNodes {
node, _ := raw.(map[string]interface{})
if node == nil || fmt.Sprint(node["type"]) != "image" {
continue
}
metadata, _ := node["metadata"].(map[string]interface{})
if metadata == nil {
metadata = map[string]interface{}{}
}
id := stringValue(node["id"])
if id == "" {
continue
}
tags := stringSlice(metadata["assetTags"])
prompt := stringValue(metadata["prompt"])
content := stringValue(metadata["content"])
if len(tags) == 0 && prompt == "" && content == "" {
continue
}
assets = append(assets, storyboardAsset{ID: id, Title: defaultString(stringValue(node["title"]), "未命名图片"), Type: "image", Tags: tags, Prompt: prompt})
if len(assets) >= 30 {
break
}
}
return assets
}
func matchStoryboardAssets(assets []storyboardAsset, shotTags []string) []storyboardAsset {
wanted := map[string]bool{}
for _, tag := range shotTags {
for _, token := range storyboardTagTokens(tag) {
wanted[token] = true
}
}
if len(wanted) == 0 {
return nil
}
matched := make([]storyboardAsset, 0)
for _, asset := range assets {
tokens := map[string]bool{}
for _, token := range storyboardTagTokens(asset.Title) {
tokens[token] = true
}
for _, tag := range asset.Tags {
for _, token := range storyboardTagTokens(tag) {
tokens[token] = true
}
}
if storyboardTokensMatch(wanted, tokens) {
matched = append(matched, asset)
}
if len(matched) >= 6 {
break
}
}
return matched
}
func storyboardTokensMatch(wanted map[string]bool, tokens map[string]bool) bool {
for want := range wanted {
if tokens[want] {
return true
}
for token := range tokens {
if meaningfulStoryboardTagToken(want) && meaningfulStoryboardTagToken(token) && (strings.Contains(token, want) || strings.Contains(want, token)) {
return true
}
}
}
return false
}
func storyboardTagTokens(value string) []string {
normalized := strings.ToLower(strings.ReplaceAll(strings.Join(strings.Fields(strings.ReplaceAll(value, ":", ":")), ""), ",", ","))
if normalized == "" {
return nil
}
tokens := []string{normalized}
if index := strings.Index(normalized, ":"); index >= 0 {
tokens = append(tokens, normalized[index+1:])
}
unique := make([]string, 0, len(tokens))
seen := map[string]bool{}
for _, token := range tokens {
if meaningfulStoryboardTagToken(token) && !seen[token] {
seen[token] = true
unique = append(unique, token)
}
}
return unique
}
func meaningfulStoryboardTagToken(value string) bool {
if len([]rune(value)) < 2 {
return false
}
switch value {
case "角色", "环境", "场景", "道具", "武器", "风格":
return false
}
return true
}
func listContent(title string, items []string) string {
if len(items) == 0 {
return title + "\n\n- 暂无明确内容。"
}
lines := []string{title, ""}
for _, item := range items {
if strings.TrimSpace(item) != "" {
lines = append(lines, "- "+item)
}
}
return strings.Join(lines, "\n")
}
func storyboardAssetsContent(assets []storyboardAsset) string {
if len(assets) == 0 {
return "当前画布暂无可用图片资产。建议先给角色、环境、道具图片添加资产标签。"
}
lines := make([]string, 0, len(assets))
for _, asset := range assets {
line := asset.Title + "\nID: " + asset.ID
if len(asset.Tags) > 0 {
line += "\n标签: " + strings.Join(asset.Tags, "、")
}
if asset.Prompt != "" {
line += "\n原提示词: " + asset.Prompt
}
lines = append(lines, line)
}
return strings.Join(lines, "\n\n")
}
func shotDescription(shot agentStoryboardShot) string {
parts := []string{shot.Description}
if strings.TrimSpace(shot.VisualPrompt) != "" {
parts = append(parts, "画面提示词:"+shot.VisualPrompt)
}
if strings.TrimSpace(shot.Camera) != "" {
parts = append(parts, "镜头:"+shot.Camera)
}
if strings.TrimSpace(shot.Motion) != "" {
parts = append(parts, "运动:"+shot.Motion)
}
if strings.TrimSpace(shot.TimeBeats) != "" {
parts = append(parts, "时间节拍:"+shot.TimeBeats)
}
filtered := make([]string, 0, len(parts))
for _, part := range parts {
if strings.TrimSpace(part) != "" {
filtered = append(filtered, part)
}
}
return strings.Join(filtered, "\n\n")
}
func buildStoryboardVideoPrompt(styleGuide string, shot agentStoryboardShot) string {
camera := defaultString(strings.TrimSpace(shot.Camera), strings.TrimSpace(shot.ShotSize)+",平视机位,中等焦段,主体与环境保持空间层次")
motion := defaultString(strings.TrimSpace(shot.Motion), "固定机位,主体在画面内完成动作")
timeBeats := defaultString(strings.TrimSpace(shot.TimeBeats), fmt.Sprintf("0-%d秒:%s", shot.Duration, strings.TrimSpace(shot.Description)))
negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、闪烁、风格突变和动作僵硬")
parts := []string{
"【氛围与画质】\n" + strings.TrimSpace(styleGuide),
"【镜头设计】\n" + strings.TrimSpace(shot.ShotSize) + ";" + camera + ";运镜:" + motion,
"【画面内容】\n" + timeBeats,
}
if strings.TrimSpace(shot.Dialogue) != "" || strings.TrimSpace(shot.AudioEffects) != "" {
parts = append(parts, "【台词/声音】\n"+strings.TrimSpace(shot.Dialogue)+";音效:"+strings.TrimSpace(shot.AudioEffects))
}
if strings.TrimSpace(shot.VideoPrompt) != "" {
parts = append(parts, "【执行约束】\n"+strings.TrimSpace(shot.VideoPrompt))
}
parts = append(parts, "【负面要求】\n"+negative)
return strings.Join(parts, "\n\n")
}
func shotComposerContent(prompt string, assets []storyboardAsset) string {
if len(assets) == 0 {
return prompt
}
lines := []string{"参考素材:"}
for _, asset := range assets {
label := asset.Title
if len(asset.Tags) > 0 {
label += "(" + strings.Join(asset.Tags, "、") + ")"
}
lines = append(lines, "- "+label+":@[node:"+asset.ID+"]")
}
lines = append(lines, "", "分镜视频提示词:", prompt)
return strings.Join(lines, "\n")
}
func shotIDs(prefix string, count int) []string {
ids := make([]string, 0, count)
for index := 0; index < count; index++ {
ids = append(ids, fmt.Sprintf("%s-shot-%d", prefix, index+1))
}
return ids
}
func stringSlice(value any) []string {
items, ok := value.([]interface{})
if !ok {
text := stringValue(value)
if text == "" {
return nil
}
return []string{text}
}
result := make([]string, 0, len(items))
for _, item := range items {
if text := strings.TrimSpace(fmt.Sprint(item)); text != "" {
result = append(result, text)
}
}
return result
}
func stringValue(value any) string {
text := strings.TrimSpace(fmt.Sprint(value))
if text == "<nil>" {
return ""
}
return text
}
func (s *Service) log(userID string, taskID string, level string, message string, payload string) error {
return s.repo.Create(&model.TaskLog{ID: newID(), UserID: userID, TaskID: taskID, Level: level, Message: message, Payload: truncateTaskLogPayload(payload)})
}
func truncateTaskLogPayload(payload string) string {
if len(payload) <= taskLogPayloadLimit {
return payload
}
end := taskLogPayloadLimit
for end > 0 && !utf8.ValidString(payload[:end]) {
end--
}
return payload[:end] + fmt.Sprintf("\n...(日志内容已截断,原始长度 %d 字符)", len(payload))
}
func (s *Service) registerActiveTask(id string, cancel context.CancelFunc) {
s.cancelMu.Lock()
defer s.cancelMu.Unlock()
s.activeCancels[id] = cancel
}
func (s *Service) unregisterActiveTask(id string) {
s.cancelMu.Lock()
defer s.cancelMu.Unlock()
delete(s.activeCancels, id)
}
func (s *Service) cancelActiveTask(id string) {
s.cancelMu.Lock()
cancel := s.activeCancels[id]
s.cancelMu.Unlock()
if cancel != nil {
cancel()
}
}
func (s *Service) markSessionFailed(task model.Task, message string) error {
if task.SessionID == "" {
return nil
}
session, err := s.repo.SessionForUser(task.UserID, task.SessionID)
if err != nil {
return err
}
session.Status = model.SessionStatusFailed
if err := s.repo.Save(session); err != nil {
return err
}
return s.repo.Create(&model.Message{ID: newID(), UserID: task.UserID, SessionID: task.SessionID, Role: "assistant", Content: defaultString(message, "会话任务失败。")})
}
func buildAgentResult(task model.Task) (map[string]any, []map[string]any) {
title := strings.TrimSpace(task.Prompt)
if len([]rune(title)) > 28 {
title = string([]rune(title)[:28]) + "..."
}
result := map[string]any{
"taskId": task.ID,
"operation": task.Operation,
"provider": defaultString(task.Provider, "internal-agent"),
"model": defaultString(task.Model, "workflow-router"),
"plan": []map[string]any{
{"kind": "script", "title": "创意脚本", "content": task.Prompt},
{"kind": "scene", "title": "主场景", "content": "根据用户输入拆解为可生成的视频场景。"},
{"kind": "shot", "title": "镜头 1", "content": "建立画面、主体、风格和运镜。"},
{"kind": "final", "title": "成片", "content": "等待视频生成 Provider 回填成片结果。"},
},
}
ops := []map[string]any{
nodeOp("script-"+task.ID, "text", "剧本 · "+title, 0, 0, "script", task.Prompt),
nodeOp("scene-"+task.ID, "text", "场景 · 主场景", 380, 0, "scene", "主场景设定、角色关系、视觉风格。"),
nodeOpWithMetadata("shot-"+task.ID, "video", "分镜 · 镜头 1", 760, 0, map[string]any{"workflowKind": "shot", "status": "idle", "generationMode": "video", "prompt": task.Prompt, "composerContent": task.Prompt, "videoEditOperation": "text_to_video"}),
nodeOp("final-"+task.ID, "video", "成片 · 待生成", 1140, 0, "final", ""),
connectOp("script-"+task.ID, "scene-"+task.ID),
connectOp("scene-"+task.ID, "shot-"+task.ID),
connectOp("shot-"+task.ID, "final-"+task.ID),
}
return result, ops
}
func buildVideoWorkflowResult(task model.Task) (map[string]any, []map[string]any) {
title := strings.TrimSpace(task.Prompt)
if len([]rune(title)) > 28 {
title = string([]rune(title)[:28]) + "..."
}
operation := defaultString(task.Operation, strings.TrimPrefix(task.Type, "video_"))
result := map[string]any{
"taskId": task.ID,
"operation": operation,
"provider": defaultString(task.Provider, "internal-agent"),
"model": defaultString(task.Model, "workflow-router"),
"plan": []map[string]any{
{"kind": "reference_set", "title": "参考素材组", "content": "收集原视频、参考图、参考音频和版本样片。"},
{"kind": "shot", "title": "编辑镜头", "content": task.Prompt},
{"kind": "final", "title": "结果版本", "content": "等待 provider 生成或人工确认后回填版本结果。"},
},
}
ops := []map[string]any{
nodeOp("video-brief-"+task.ID, "text", "编辑需求 · "+title, 0, 0, "script", task.Prompt),
nodeOpWithMetadata("video-ref-"+task.ID, "text", "参考素材组", 380, 0, map[string]any{"workflowKind": "reference_set", "status": "idle", "content": "原片、参考图、参考音频、风格板或历史版本。", "videoEditOperation": operation}),
nodeOpWithMetadata("video-shot-"+task.ID, "video", "视频任务 · "+operation, 760, 0, map[string]any{"workflowKind": "shot", "status": "idle", "generationMode": "video", "prompt": task.Prompt, "composerContent": task.Prompt, "videoEditOperation": operation}),
nodeOpWithMetadata("video-result-"+task.ID, "video", "结果版本 · 待回填", 1140, 0, map[string]any{"workflowKind": "final", "status": "idle", "videoEditOperation": operation, "versionLabel": "v1"}),
connectOp("video-brief-"+task.ID, "video-ref-"+task.ID),
connectOp("video-ref-"+task.ID, "video-shot-"+task.ID),
connectOp("video-shot-"+task.ID, "video-result-"+task.ID),
}
return result, ops
}
func nodeOp(id string, nodeType string, title string, x int, y int, workflowKind string, content string) map[string]any {
return nodeOpWithMetadata(id, nodeType, title, x, y, map[string]any{"content": content, "workflowKind": workflowKind, "status": "idle"})
}
func nodeOpWithMetadata(id string, nodeType string, title string, x int, y int, metadata map[string]any) map[string]any {
return map[string]any{
"type": "add_node",
"id": id,
"nodeType": nodeType,
"title": title,
"position": map[string]int{"x": x, "y": y},
"metadata": metadata,
}
}
func connectOp(from string, to string) map[string]any {
return map[string]any{"type": "connect_nodes", "fromNodeId": from, "toNodeId": to}
}
func ptr[T any](value T) *T {
return &value
}
func shortTitle(value string, max int) string {
title := strings.TrimSpace(value)
if title == "" {
title = "影视分镜"
}
if len([]rune(title)) > max {
return string([]rune(title)[:max]) + "..."
}
return title
}
func defaultString(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func newID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}