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

256 lines
12 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"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"infinite-canvas/backend/internal/model"
)
func (s *Service) processAgentStoryboardTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) {
input, err := parseStoryboardTaskInput(task, "Agent 会话")
if err != nil {
return nil, nil, err
}
plan, assets, err := s.generateStoryboardPlan(ctx, task, input, 0, 0)
if err != nil {
return nil, nil, err
}
return s.buildAgentStoryboardResult(task, plan, assets, input.ProjectStyle)
}
func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) {
input, err := parseStoryboardTaskInput(task, "脚本任务")
if err != nil {
return nil, nil, err
}
plan, _, err := s.generateStoryboardPlan(ctx, task, input, input.ShotDuration, input.ShotCount)
if err != nil {
return nil, nil, err
}
rows := make([]map[string]any, 0, len(plan.Shots))
for index, shot := range plan.Shots {
imagePromptVariables := storyboardImagePromptValues(input.ProjectStyle.Prompt, plan.StyleGuide, shot)
videoPromptVariables := storyboardVideoPromptValues(input.ProjectStyle.Prompt, plan.StyleGuide, shot)
imagePrompt, promptErr := s.compileStoryboardImagePrompt(task.UserID, input.ProjectStyle.Prompt, plan.StyleGuide, shot)
if promptErr != nil {
return nil, nil, promptErr
}
videoPrompt, promptErr := s.compileStoryboardVideoPrompt(task.UserID, input.ProjectStyle.Prompt, plan.StyleGuide, shot)
if promptErr != nil {
return nil, nil, promptErr
}
rows = append(rows, map[string]any{
"shotNumber": index + 1, "durationSeconds": shot.Duration, "plotDescription": shot.Description,
"dialogue": shot.Dialogue, "characters": storyboardRowCharacters(shot, input.Characters), "shotSize": shot.ShotSize, "emotion": shot.Emotion,
"lightingAndAtmosphere": shot.Lighting, "audioEffects": shot.AudioEffects,
"imageGenerationPrompt": imagePrompt, "videoMotionPrompt": videoPrompt,
"imagePromptTemplateVariables": imagePromptVariables, "videoPromptTemplateVariables": videoPromptVariables,
"camera": shot.Camera, "motion": shot.Motion, "timeBeats": shot.TimeBeats, "negativePrompt": shot.Negative,
"narrativeIntent": shot.Intent, "viewerPOV": shot.ViewerPOV, "performanceBlocking": shot.Performance,
"mustHave": shot.MustHave, "optionalDetails": shot.Optional, "continuityOut": shot.ContinuityOut,
"assetBindings": shot.AssetRefs,
})
}
return map[string]interface{}{"title": plan.Title, "rows": rows}, nil, nil
}
const (
maxStoryboardRepairAttempts = 1
storyboardRepairMaxDuration = 4 * time.Minute
storyboardFinalizeReserve = 30 * time.Second
storyboardMinimumRepairTime = 90 * time.Second
)
func (s *Service) repairStoryboardPlan(ctx context.Context, task model.Task, input agentStoryboardInput, config providerConfig, originalText string, validationErr error, shotDuration int, shotCount int) (agentStoryboardPlan, error) {
currentText := originalText
currentErr := validationErr
for attempt := 1; attempt <= maxStoryboardRepairAttempts; attempt++ {
_ = s.log(task.UserID, task.ID, "warn", "分镜结构校验失败", fmt.Sprintf("第 %d 次修复前:%s", attempt, currentErr.Error()))
if err := s.repo.UpdateTaskProgress(task.ID, "修复分镜结构", 55+attempt*10); err != nil {
return agentStoryboardPlan{}, fmt.Errorf("更新分镜修复进度失败,上游修复请求未发出:%w", err)
}
repairPrompt, promptErr := s.buildStoryboardRepairPrompt(task.UserID, task.Prompt, currentErr, input, currentText)
if promptErr != nil {
return agentStoryboardPlan{}, promptErr
}
repairCtx, cancel, budgetErr := storyboardRepairContext(ctx)
if budgetErr != nil {
return agentStoryboardPlan{}, budgetErr
}
repaired, repairErr := runTextTask(withProviderRequestKind(repairCtx, "repair"), canvasGenerationInput{Mode: "text", Prompt: repairPrompt, Config: config, StreamText: true, MaxOutputTokens: storyboardOutputTokenLimit(shotCount)})
cancel()
if repairErr != nil {
return agentStoryboardPlan{}, fmt.Errorf("分镜结构修复失败:%w", repairErr)
}
repairedText, _ := repaired["text"].(string)
plan, parseErr := parseAgentStoryboardPlan(repairedText)
if parseErr == nil {
normalizeAutomaticStoryboardDurations(&plan, shotDuration)
parseErr = validateStoryboardPlan(&plan, shotDuration, shotCount, input.Characters, input.CanvasAssets)
}
if parseErr == nil {
return plan, nil
}
currentText = repairedText
currentErr = parseErr
}
return agentStoryboardPlan{}, fmt.Errorf("分镜模型结构修复后仍不合法:%w", currentErr)
}
func providerConfigReady(config providerConfig) bool {
return strings.TrimSpace(config.Model) != "" && (strings.TrimSpace(config.ChannelID) != "" || (strings.TrimSpace(config.BaseURL) != "" && strings.TrimSpace(config.APIKey) != ""))
}
func parseStoryboardTaskInput(task model.Task, label string) (agentStoryboardInput, error) {
input := agentStoryboardInput{}
if strings.TrimSpace(task.InputJSON) == "" {
return input, nil
}
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
return agentStoryboardInput{}, fmt.Errorf("%s输入解析失败:%w", label, err)
}
return input, nil
}
func (s *Service) generateStoryboardPlan(ctx context.Context, task model.Task, input agentStoryboardInput, shotDuration int, shotCount int) (agentStoryboardPlan, []storyboardAsset, error) {
ctx = withProtocolRegistry(ctx, s.protocolRegistry())
if !providerConfigReady(input.Config) {
return agentStoryboardPlan{}, nil, errors.New("请先配置可用的文本模型")
}
if err := validateStoryboardContext(input.ProjectStyle, input.Characters); err != nil {
return agentStoryboardPlan{}, nil, err
}
assets := normalizeStoryboardAssets(input.CanvasAssets)
if len(assets) == 0 {
assets = normalizeStoryboardAssets(extractStoryboardAssets(input.CanvasSnapshot))
}
input.CanvasAssets = assets
config, err := s.resolveProviderConfig(input.Config)
if err != nil {
return agentStoryboardPlan{}, nil, err
}
ctx = withProviderOutboundPolicy(ctx, config)
plannerPrompt, err := s.buildAgentStoryboardPlannerPrompt(task.UserID, task.Prompt, input.Requirements, assets, input.ProjectStyle, input.Characters, shotDuration, shotCount)
if err != nil {
return agentStoryboardPlan{}, nil, err
}
result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: plannerPrompt, Config: config, StreamText: true, MaxOutputTokens: storyboardOutputTokenLimit(shotCount)})
if err != nil {
return agentStoryboardPlan{}, nil, err
}
text, _ := result["text"].(string)
plan, err := parseAgentStoryboardPlan(text)
if err == nil {
normalizeAutomaticStoryboardDurations(&plan, shotDuration)
err = validateStoryboardPlan(&plan, shotDuration, shotCount, input.Characters, assets)
}
if err != nil {
plan, err = s.repairStoryboardPlan(ctx, task, input, config, text, err, shotDuration, shotCount)
if err != nil {
return agentStoryboardPlan{}, nil, err
}
}
if complexityErr := validateStoryboardComplexity(plan); complexityErr != nil {
_ = s.log(task.UserID, task.ID, "warn", "分镜复杂度建议", complexityErr.Error())
}
return plan, assets, nil
}
func storyboardRepairContext(ctx context.Context) (context.Context, context.CancelFunc, error) {
deadline, ok := ctx.Deadline()
if !ok {
repairCtx, cancel := context.WithTimeout(ctx, storyboardRepairMaxDuration)
return repairCtx, cancel, nil
}
remaining := time.Until(deadline)
if remaining <= storyboardFinalizeReserve+storyboardMinimumRepairTime {
return nil, nil, fmt.Errorf("分镜结构校验失败,但任务剩余时间不足以安全修复:剩余 %s", remaining.Round(time.Second))
}
repairDuration := min(storyboardRepairMaxDuration, remaining-storyboardFinalizeReserve)
repairCtx, cancel := context.WithTimeout(ctx, repairDuration)
return repairCtx, cancel, nil
}
func (s *Service) buildAgentStoryboardResult(task model.Task, plan agentStoryboardPlan, assets []storyboardAsset, projectStyle storyboardProjectStyle) (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", "项目画风 · "+shortTitle(projectStyle.Title, 24), styleX, 0, map[string]any{"workflowKind": "styleboard", "workflowTitle": "项目画风", "workflowDescription": plan.StyleGuide, "stylePresetId": projectStyle.PresetID, "styleProfileJson": projectStyle.ProfileJSON, "status": "success", "content": projectStyle.Prompt, "prompt": projectStyle.Prompt}),
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 {
videoPrompt, err := s.compileStoryboardVideoPrompt(task.UserID, projectStyle.Prompt, plan.StyleGuide, shot)
if err != nil {
return nil, nil, err
}
shotID := fmt.Sprintf("%s-shot-%d", prefix, index+1)
matchedAssets := resolveStoryboardAssets(assets, shot.AssetRefs)
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": videoPrompt,
"composerContent": shotComposerContent(videoPrompt, matchedAssets),
"videoEditOperation": "text_to_video",
"assetBindings": shot.AssetRefs,
"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, "assetBindings": shot.AssetRefs, "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 (s *Service) compileStoryboardImagePrompt(userID string, projectStyle string, styleGuide string, shot agentStoryboardShot) (string, error) {
compiled, err := s.compilePrompt(userID, promptOperationStoryboardFirstFrame, storyboardImagePromptValues(projectStyle, styleGuide, shot))
return compiled.Content, err
}
func (s *Service) compileStoryboardVideoPrompt(userID string, projectStyle string, styleGuide string, shot agentStoryboardShot) (string, error) {
compiled, err := s.compilePrompt(userID, promptOperationStoryboardVideo, storyboardVideoPromptValues(projectStyle, styleGuide, shot))
return compiled.Content, err
}