feat: FormatAdapter 接入 splitsprite+gifmaker 管线

- FormatAdapter: 精灵表模式(单图)自动调用 splitsprite.Process 拆分帧
- 拆分后每帧编码为独立 PNG Asset,Metadata 包含 GIFPreview 字节
- 多图/非精灵表模式保持原样透传
- AssetParams 新增 GridRows/GridCols 覆盖投影检测
- AssetMetadata 新增 GIFPreview 字段
This commit is contained in:
2026-05-25 18:01:51 +08:00
parent 8309d32d4b
commit d5e2d7fa53
2 changed files with 91 additions and 13 deletions
+84 -10
View File
@@ -1,10 +1,16 @@
package service package service
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"image/png"
"strings" "strings"
"gen2d/internal/logger"
"gen2d/pkg/gifmaker"
"gen2d/pkg/splitsprite"
"github.com/cloudwego/eino/compose" "github.com/cloudwego/eino/compose"
) )
@@ -127,7 +133,7 @@ var qualitySupervisorNode = compose.InvokableLambda(func(ctx context.Context, im
return input, nil return input, nil
}) })
// formatAdapterNode 节点:从 state 读取图片,格式转换,组装输出。 // formatAdapterNode 节点:精灵表格式时调用 splitsprite 拆分 + gifmaker 生成 GIF 预览。
var formatAdapterNode = compose.InvokableLambda(func(ctx context.Context, input PipelineInput) (PipelineOutput, error) { var formatAdapterNode = compose.InvokableLambda(func(ctx context.Context, input PipelineInput) (PipelineOutput, error) {
var images []GeneratedImage var images []GeneratedImage
_ = compose.ProcessState[*PipelineState](ctx, func(_ context.Context, state *PipelineState) error { _ = compose.ProcessState[*PipelineState](ctx, func(_ context.Context, state *PipelineState) error {
@@ -135,6 +141,18 @@ var formatAdapterNode = compose.InvokableLambda(func(ctx context.Context, input
return nil 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)) assets := make([]Asset, len(images))
for i, img := range images { for i, img := range images {
assets[i] = Asset{ assets[i] = Asset{
@@ -144,23 +162,79 @@ var formatAdapterNode = compose.InvokableLambda(func(ctx context.Context, input
} }
} }
resolution := input.Params.Resolution return PipelineOutput{
if resolution <= 0 { Assets: assets,
resolution = 64 Metadata: AssetMetadata{
}
metadata := AssetMetadata{
FrameWidth: resolution, FrameWidth: resolution,
FrameHeight: resolution, FrameHeight: resolution,
FrameCount: len(images), FrameCount: len(images),
Directions: input.Params.Frames.Directions, 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.Error("format_adapter split sprite sheet failed", "error", err)
return PipelineOutput{}, fmt.Errorf("split sprite sheet: %w", err)
}
l.Info("format_adapter split sprite sheet", "frame_count", len(frames))
// 帧 → Asset
assets := make([]Asset, 0, len(frames))
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 预览
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())
}
fw, fh := 0, 0
if len(frames) > 0 {
b := frames[0].Bounds()
fw, fh = b.Dx(), b.Dy()
} }
return PipelineOutput{ return PipelineOutput{
Assets: assets, Assets: assets,
Metadata: metadata, Metadata: AssetMetadata{
FrameWidth: fw,
FrameHeight: fh,
FrameCount: len(frames),
Directions: params.Frames.Directions,
GIFPreview: gifBuf.Bytes(),
},
}, nil }, nil
}) }
// buildStyleDescription 将风格键值对转为自然语言描述,供 PromptAgent 注入。 // buildStyleDescription 将风格键值对转为自然语言描述,供 PromptAgent 注入。
func buildStyleDescription(projectStyle, taskStyle map[string]string) string { func buildStyleDescription(projectStyle, taskStyle map[string]string) string {
+4
View File
@@ -36,6 +36,9 @@ type AssetParams struct {
Resolution int Resolution int
Frames FrameParams Frames FrameParams
Format string // "spritesheet" / "individual" Format string // "spritesheet" / "individual"
// GridRows / GridCols override projection-based split for sprite sheets.
GridRows int
GridCols int
} }
// FrameParams 帧参数 // FrameParams 帧参数
@@ -65,4 +68,5 @@ type AssetMetadata struct {
FrameHeight int FrameHeight int
FrameCount int FrameCount int
Directions int Directions int
GIFPreview []byte `json:"-"` // animated GIF preview (not serialized)
} }