ab2b72e746
- 工程层面:新增 PROJECT_TYPES(2D平台跳跃/角色扮演等8种),替代原先与素材生成重复的风格标签栏 - 素材生成层面:StyleSelector 作为素材风格主配置项,新增 CustomTagsEditor 支持任务级自定义标签 - extractTags 合并工程类型、风格分类、工程自定义标签、任务自定义标签 - StyleSelector 支持 categories 参数复用 - 后端:spritesheet 拆分失败回退为单帧、GIF 上传为独立素材、AssetResponse 增加 metadata 字段
287 lines
8.7 KiB
Go
Executable File
287 lines
8.7 KiB
Go
Executable File
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
|
||
}
|