Files
gen2d/backend/internal/service/nodes.go
T
Gmarker689 ab2b72e746 fix: 工程类型与素材风格标签分离,支持双栏自定义标签
- 工程层面:新增 PROJECT_TYPES(2D平台跳跃/角色扮演等8种),替代原先与素材生成重复的风格标签栏
- 素材生成层面:StyleSelector 作为素材风格主配置项,新增 CustomTagsEditor 支持任务级自定义标签
- extractTags 合并工程类型、风格分类、工程自定义标签、任务自定义标签
- StyleSelector 支持 categories 参数复用
- 后端:spritesheet 拆分失败回退为单帧、GIF 上传为独立素材、AssetResponse 增加 metadata 字段
2026-05-25 19:31:36 +08:00

287 lines
8.7 KiB
Go
Executable File
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 (
"bytes"
"context"
"fmt"
"image"
"image/png"
"strings"
"gen2d/internal/logger"
"gen2d/pkg/gifmaker"
"gen2d/pkg/splitsprite"
"github.com/cloudwego/eino/compose"
)
// promptOptimizerNode 节点:合并风格描述、重试信息与技术参数,输出最终提示词。
// 提示词优化已由前端在提交前完成,管线内不再重复调用 PromptAgent。
var promptOptimizerNode = compose.InvokableLambda(func(ctx context.Context, in PipelineInput) (string, error) {
// 合并风格描述
styleDesc := buildStyleDescription(in.ProjectStyle, in.TaskStyle)
if styleDesc != "" {
if in.Prompt != "" {
in.Prompt = in.Prompt + "。" + styleDesc
} else {
in.Prompt = styleDesc
}
}
// 注入重试原因
if in.RejectReason != "" {
if in.Prompt != "" {
in.Prompt = in.Prompt + "。注意修正以下问题:" + in.RejectReason
} else {
in.Prompt = "修正以下问题:" + in.RejectReason
}
}
if in.Prompt == "" {
return "", fmt.Errorf("pipeline: prompt is empty")
}
// 追加技术参数段,不再调用 PromptAgent
return appendTechNotes(in.Prompt, in.AssetType, in.Params), nil
})
// promptOptimizerPreHandler 首次运行时保存输入到 state;重试时注入 RejectReason。
func promptOptimizerPreHandler(ctx context.Context, in PipelineInput, state *PipelineState) (PipelineInput, error) {
if state.RetryCount == 0 {
state.Input = in
reportProgress(ctx, "prompt_builder", 10)
} else if state.RejectReason != "" {
in.RejectReason = state.RejectReason
reportProgress(ctx, "prompt_builder", 30+state.RetryCount*10)
}
return in, nil
}
// promptOptimizerPostHandler 将最终提示词写入全局状态。
func promptOptimizerPostHandler(ctx context.Context, out string, state *PipelineState) (string, error) {
state.FinalPrompt = out
reportProgress(ctx, "asset_generator", 35)
return out, nil
}
// AssetGenerator 节点:调用 AI 推理 API 出图。
var assetGeneratorNode = compose.InvokableLambda(func(ctx context.Context, prompt string) ([]GeneratedImage, error) {
var params AssetParams
_ = compose.ProcessState[*PipelineState](ctx, func(_ context.Context, state *PipelineState) error {
params = state.Input.Params
return nil
})
return GenerateImages(ctx, prompt, params)
})
// assetGeneratorPostHandler 将原始图片写入全局状态。
func assetGeneratorPostHandler(ctx context.Context, out []GeneratedImage, state *PipelineState) ([]GeneratedImage, error) {
state.RawImages = out
reportProgress(ctx, "quality_supervisor", 60)
return out, nil
}
// QualitySupervisor 节点:质检,设置路由目标。
var qualitySupervisorNode = compose.InvokableLambda(func(ctx context.Context, images []GeneratedImage) (PipelineInput, error) {
var input PipelineInput
err := compose.ProcessState[*PipelineState](ctx, func(_ context.Context, state *PipelineState) error {
state.RawImages = images
style := mergeStyle(state.Input.ProjectStyle, state.Input.TaskStyle)
pass, reason, checkErr := CheckQuality(ctx, images, style)
if checkErr != nil {
return fmt.Errorf("quality check: %w", checkErr)
}
state.PassQuality = pass
if !pass {
state.RejectReason = reason
}
if pass {
state.NextNode = nodeFormatAdapter
reportProgress(ctx, "format_adapter", 85)
} else if state.RetryCount >= 3 {
state.NextNode = nodeFormatAdapter
reportProgress(ctx, "format_adapter", 85)
} else {
state.RetryCount++
state.NextNode = nodePromptOptimizer
reportProgress(ctx, "quality_supervisor", 50+state.RetryCount*10)
}
input = state.Input
return nil
})
if err != nil {
return PipelineInput{}, err
}
return input, nil
})
// formatAdapterNode 节点:精灵表格式时调用 splitsprite 拆分 + gifmaker 生成 GIF 预览。
var formatAdapterNode = compose.InvokableLambda(func(ctx context.Context, input PipelineInput) (PipelineOutput, error) {
var images []GeneratedImage
_ = compose.ProcessState[*PipelineState](ctx, func(_ context.Context, state *PipelineState) error {
images = state.RawImages
return nil
})
params := input.Params
resolution := params.Resolution
if resolution <= 0 {
resolution = 64
}
// 精灵表模式:单张图时拆分 + GIF 预览;多图时已是独立帧,透传
if params.Format == "spritesheet" && len(images) == 1 {
return processSpriteSheet(ctx, images[0], params, resolution)
}
// 普通模式:原样透传
assets := make([]Asset, len(images))
for i, img := range images {
assets[i] = Asset{
Data: img.Data,
Format: img.Format,
URL: fmt.Sprintf("output/%d.%s", i, img.Format),
}
}
return PipelineOutput{
Assets: assets,
Metadata: AssetMetadata{
FrameWidth: resolution,
FrameHeight: resolution,
FrameCount: len(images),
Directions: params.Frames.Directions,
},
}, nil
})
// processSpriteSheet 将单张精灵表拆分为独立帧并生成 GIF 预览。
// 拆分失败时回退为单帧,不阻塞管线。
func processSpriteSheet(ctx context.Context, img GeneratedImage, params AssetParams, resolution int) (PipelineOutput, error) {
l := logger.FromCtx(ctx)
src, err := png.Decode(bytes.NewReader(img.Data))
if err != nil {
l.Error("format_adapter decode sprite sheet failed", "error", err)
return PipelineOutput{}, fmt.Errorf("decode sprite sheet: %w", err)
}
opts := splitsprite.DefaultOptions()
if params.GridRows > 0 && params.GridCols > 0 {
opts.GridRows = params.GridRows
opts.GridCols = params.GridCols
}
frames, err := splitsprite.Process(src, opts)
if err != nil {
l.Warn("format_adapter split sprite sheet failed, falling back to single frame", "error", err)
// 回退:整张图作为单帧
frames = []image.Image{src}
}
l.Info("format_adapter split sprite sheet", "frame_count", len(frames))
// 帧 → Asset(先保留原始精灵表,再追加拆分后的帧)
assets := make([]Asset, 0, len(frames)+2)
assets = append(assets, Asset{
Data: img.Data,
Format: img.Format,
URL: "output/spritesheet.png",
})
for i, f := range frames {
var buf bytes.Buffer
if err := png.Encode(&buf, f); err != nil {
l.Error("format_adapter encode frame failed", "error", err)
return PipelineOutput{}, fmt.Errorf("encode frame %d: %w", i, err)
}
assets = append(assets, Asset{
Data: buf.Bytes(),
Format: "png",
URL: fmt.Sprintf("output/frame_%03d.png", i),
})
}
// GIF 预览 → Asset(排在帧之后)
var gifURL string
var gifBuf bytes.Buffer
if err := gifmaker.Encode(&gifBuf, frames, nil); err != nil {
l.Warn("format_adapter generate GIF preview failed", "error", err)
} else {
l.Info("format_adapter generated GIF preview", "size_bytes", gifBuf.Len())
gifURL = "output/preview.gif"
assets = append(assets, Asset{
Data: gifBuf.Bytes(),
Format: "gif",
URL: gifURL,
})
}
fw, fh := 0, 0
if len(frames) > 0 {
b := frames[0].Bounds()
fw, fh = b.Dx(), b.Dy()
}
return PipelineOutput{
Assets: assets,
Metadata: AssetMetadata{
FrameWidth: fw,
FrameHeight: fh,
FrameCount: len(frames),
Directions: params.Frames.Directions,
GIFURL: gifURL,
},
}, nil
}
// buildStyleDescription 将风格键值对转为自然语言描述,供 PromptAgent 注入。
func buildStyleDescription(projectStyle, taskStyle map[string]string) string {
merged := mergeStyle(projectStyle, taskStyle)
if len(merged) == 0 {
return ""
}
var parts []string
for k, v := range merged {
parts = append(parts, fmt.Sprintf("%s: %s", k, v))
}
return "风格约束:" + strings.Join(parts, ";")
}
// appendTechNotes 在提示词末尾追加技术参数段。
func appendTechNotes(prompt, assetType string, params AssetParams) string {
var parts []string
if prompt != "" {
parts = append(parts, prompt)
}
parts = append(parts, fmt.Sprintf("素材类型: %s", assetType))
if params.Resolution > 0 {
parts = append(parts, fmt.Sprintf("分辨率: %d", params.Resolution))
}
if params.Frames.Directions > 0 {
parts = append(parts, fmt.Sprintf("方向数: %d", params.Frames.Directions))
}
if params.Frames.FramesPerDirection > 0 {
parts = append(parts, fmt.Sprintf("每方向帧数: %d", params.Frames.FramesPerDirection))
}
if params.Format == "spritesheet" {
parts = append(parts, "输出格式: spritesheet(将所有帧排列在一张图上,帧之间用8-16像素纯白间隙分隔,等间距网格布局)")
} else if params.Format != "" {
parts = append(parts, fmt.Sprintf("输出格式: %s", params.Format))
}
return strings.Join(parts, ";")
}
// mergeStyle 合并工程风格与任务风格覆盖,任务同名键覆盖工程。
func mergeStyle(projectStyle, taskStyle map[string]string) map[string]string {
result := make(map[string]string)
for k, v := range projectStyle {
result[k] = v
}
for k, v := range taskStyle {
result[k] = v
}
return result
}