!59 feat: FormatAdapter 接入 splitsprite+gifmaker 管线

Merge pull request !59 from 郭永昊/feature/prompt-optimize-layout-modes
This commit is contained in:
2026-05-25 10:03:07 +00:00
committed by Gitee
8 changed files with 790 additions and 55 deletions
+2
View File
@@ -26,3 +26,5 @@ backend/main
# Generated output # Generated output
generation/ generation/
backend/test_output/
backend/test_prompt_to_gif
+86 -12
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{
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)
} }
metadata := AssetMetadata{ opts := splitsprite.DefaultOptions()
FrameWidth: resolution, if params.GridRows > 0 && params.GridCols > 0 {
FrameHeight: resolution, opts.GridRows = params.GridRows
FrameCount: len(images), opts.GridCols = params.GridCols
Directions: input.Params.Frames.Directions, }
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 {
+79 -14
View File
@@ -26,7 +26,7 @@ func InitLLMConfig(cfg config.LLMConfig) {
// PromptAgentInput 提示词优化 Agent 输入。 // PromptAgentInput 提示词优化 Agent 输入。
type PromptAgentInput struct { type PromptAgentInput struct {
Tags []string `json:"tags"` // 用户选择的标签 Tags []string `json:"tags"` // 用户选择的标签
AssetType string `json:"assetType"` // 素材类型:sprite/background/ui/animation AssetType string `json:"assetType"` // 素材类型:sprite/background/ui/animation
Prompt string `json:"prompt,omitempty"` // 用户原始提示词 Prompt string `json:"prompt,omitempty"` // 用户原始提示词
UserNote string `json:"userNote,omitempty"` // 用户额外描述 UserNote string `json:"userNote,omitempty"` // 用户额外描述
@@ -89,7 +89,24 @@ func buildMetaPrompt(in PromptAgentInput) string {
sb.WriteString("输出要求:\n") sb.WriteString("输出要求:\n")
sb.WriteString("1. 三段式结构:【主题】描述画面主体与场景,【风格】描述艺术风格与视觉特征,【技术】描述分辨率、方向数等技术参数\n") sb.WriteString("1. 三段式结构:【主题】描述画面主体与场景,【风格】描述艺术风格与视觉特征,【技术】描述分辨率、方向数等技术参数\n")
sb.WriteString("2. 使用专业术语,描述具体、可执行\n") sb.WriteString("2. 使用专业术语,描述具体、可执行\n")
sb.WriteString("3. 风格一致,适合游戏资产管线\n\n") sb.WriteString("3. 风格一致,适合游戏资产管线\n")
sb.WriteString("4. 根据用户标签和描述,识别素材布局模式,在【技术】段明确标注格式:\n")
sb.WriteString(" *** 关键:帧间间隙必须留足8-16px纯白色(#FFFFFF)空白区域,间隙内不得有任何像素,确保投影法能可靠检测到间隙 ***\n")
switch in.AssetType {
case "sprite":
sb.WriteString(" - 单个精灵(默认):独立PNG,纯白色背景(#FFFFFF),描述为单个角色立绘/道具图标\n")
sb.WriteString(" - 精灵表(spritesheet):角色/道具按行列等距网格排列,纯白色背景(#FFFFFF),帧间留8-16px纯白间隙(无像素残留),标注行列数\n")
case "background":
sb.WriteString(" - 独立场景(默认):单张完整背景图,层次分明\n")
sb.WriteString(" - 场景瓦片集(tileset):地形/建筑元件按规则网格排列,纯白色背景(#FFFFFF),元件间留8-16px纯白间隙,标注行列数与瓦片尺寸\n")
case "ui":
sb.WriteString(" - 独立UI元素(默认):单个按钮/面板/图标,纯白色背景(#FFFFFF),独立PNG\n")
sb.WriteString(" - UI瓦片集(tileset):UI元件按规则网格排列,纯白色背景(#FFFFFF),元件间留8-16px纯白间隙,标注行列数,支持九宫格缩放\n")
case "animation":
sb.WriteString(" - 帧序列(默认):连续动画帧,独立帧文件或帧条带,纯白色背景(#FFFFFF)\n")
sb.WriteString(" - 动画精灵表(spritesheet):动画帧按行列等距网格排列,纯白色背景(#FFFFFF),帧间留8-16px纯白间隙(无像素残留),标注行列数与方向数\n")
}
sb.WriteString("\n\n")
tagStr := strings.Join(in.Tags, "、") tagStr := strings.Join(in.Tags, "、")
sb.WriteString(fmt.Sprintf("用户选择标签: %s\n", tagStr)) sb.WriteString(fmt.Sprintf("用户选择标签: %s\n", tagStr))
@@ -242,12 +259,13 @@ func parseStreamResponse(r io.Reader) (string, error) {
// ======================== 模板回退 ======================== // ======================== 模板回退 ========================
func fallbackRefine(metaPrompt string) PromptAgentOutput { func fallbackRefine(metaPrompt string) PromptAgentOutput {
tags, assetType := parseTagsFromMeta(metaPrompt) tags, assetType, userPrompt := parseTagsFromMeta(metaPrompt)
prompt := generateStructuredPrompt(tags, assetType) isSheet := isSheetRequest(tags, userPrompt)
prompt := generateStructuredPrompt(tags, assetType, isSheet)
return PromptAgentOutput{Prompt: prompt, RawText: prompt} return PromptAgentOutput{Prompt: prompt, RawText: prompt}
} }
func parseTagsFromMeta(meta string) (tags []string, assetType string) { func parseTagsFromMeta(meta string) (tags []string, assetType string, userPrompt string) {
lines := strings.Split(meta, "\n") lines := strings.Split(meta, "\n")
for _, line := range lines { for _, line := range lines {
if strings.HasPrefix(line, "用户选择标签:") { if strings.HasPrefix(line, "用户选择标签:") {
@@ -262,34 +280,69 @@ func parseTagsFromMeta(meta string) (tags []string, assetType string) {
if strings.HasPrefix(line, "素材类型:") { if strings.HasPrefix(line, "素材类型:") {
assetType = strings.TrimSpace(strings.TrimPrefix(line, "素材类型: ")) assetType = strings.TrimSpace(strings.TrimPrefix(line, "素材类型: "))
} }
if strings.HasPrefix(line, "用户原始描述:") {
userPrompt = strings.TrimSpace(strings.TrimPrefix(line, "用户原始描述: "))
}
} }
return return
} }
func generateStructuredPrompt(tags []string, assetType string) string { // isSheetRequest 从标签和提示词中识别是否为网格/瓦片集/精灵表模式。
func isSheetRequest(tags []string, prompt string) bool {
keywords := []string{"精灵表", "spritesheet", "瓦片集", "tileset", "tilemap"}
for _, t := range tags {
tLower := strings.ToLower(t)
for _, kw := range keywords {
if strings.Contains(tLower, kw) {
return true
}
}
}
promptLower := strings.ToLower(prompt)
for _, kw := range keywords {
if strings.Contains(promptLower, kw) {
return true
}
}
return false
}
func generateStructuredPrompt(tags []string, assetType string, isSheet bool) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString("【主题】") sb.WriteString("【主题】")
sb.WriteString(buildSubject(tags, assetType)) sb.WriteString(buildSubject(tags, assetType, isSheet))
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString("【风格】") sb.WriteString("【风格】")
sb.WriteString(buildStyle(tags)) sb.WriteString(buildStyle(tags))
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString("【技术】") sb.WriteString("【技术】")
sb.WriteString(buildTechNotes(assetType)) sb.WriteString(buildTechNotes(assetType, isSheet))
sb.WriteString("\n") sb.WriteString("\n")
return sb.String() return sb.String()
} }
func buildSubject(tags []string, assetType string) string { func buildSubject(tags []string, assetType string, isSheet bool) string {
tagStr := strings.Join(tags, "、") tagStr := strings.Join(tags, "、")
switch assetType { switch assetType {
case "sprite": case "sprite":
if isSheet {
return fmt.Sprintf("一个融合%s元素的游戏角色精灵表,角色按行列等距网格排列,轮廓清晰,适合作为2D游戏角色", tagStr)
}
return fmt.Sprintf("一个融合%s元素的游戏角色精灵图,正面站立姿势,轮廓清晰,适合作为2D游戏角色", tagStr) return fmt.Sprintf("一个融合%s元素的游戏角色精灵图,正面站立姿势,轮廓清晰,适合作为2D游戏角色", tagStr)
case "background": case "background":
if isSheet {
return fmt.Sprintf("一套%s风格的游戏场景瓦片集,地形/建筑元件按规则网格排列,适合2D游戏地图拼接", tagStr)
}
return fmt.Sprintf("一个%s风格的游戏场景背景,层次分明,包含前景、中景和远景", tagStr) return fmt.Sprintf("一个%s风格的游戏场景背景,层次分明,包含前景、中景和远景", tagStr)
case "ui": case "ui":
if isSheet {
return fmt.Sprintf("一套%s风格的UI瓦片集,UI元件按规则网格排列,适合脚本一键拆分", tagStr)
}
return fmt.Sprintf("一套%s风格的游戏UI元素,包括按钮、面板和图标", tagStr) return fmt.Sprintf("一套%s风格的游戏UI元素,包括按钮、面板和图标", tagStr)
case "animation": case "animation":
if isSheet {
return fmt.Sprintf("一个%s风格的角色动画精灵表,动画帧按行列等距网格排列,动作流畅连贯", tagStr)
}
return fmt.Sprintf("一个%s风格的角色动画帧序列,动作流畅连贯", tagStr) return fmt.Sprintf("一个%s风格的角色动画帧序列,动作流畅连贯", tagStr)
default: default:
return fmt.Sprintf("一个%s风格的游戏素材,高质量,适合2D游戏使用", tagStr) return fmt.Sprintf("一个%s风格的游戏素材,高质量,适合2D游戏使用", tagStr)
@@ -304,17 +357,29 @@ func buildStyle(tags []string) string {
return strings.Join(parts, ";") return strings.Join(parts, ";")
} }
func buildTechNotes(assetType string) string { func buildTechNotes(assetType string, isSheet bool) string {
switch assetType { switch assetType {
case "sprite": case "sprite":
return "输出格式: spritesheet;分辨率: 64x64 或 128x128;透明背景" if isSheet {
return "输出格式: spritesheet;帧间留8-16px纯白间隙(间隙内无任何像素),行/列间隙完全相等;纯白色背景(#FFFFFF,无渐变无噪点);标注行列数"
}
return "输出格式: 独立PNG;纯白色背景(#FFFFFF);尺寸: 按角色比例适配"
case "background": case "background":
if isSheet {
return "输出格式: 场景瓦片集(tileset);规则网格排列;纯白色背景(#FFFFFF);元件间留8-16px纯白间隙;标注行列数与瓦片尺寸;确保无缝拼接"
}
return "输出格式: 独立PNG;分辨率: 1920x1080;层次分明的前中后景" return "输出格式: 独立PNG;分辨率: 1920x1080;层次分明的前中后景"
case "ui": case "ui":
return "输出格式: 独立PNG素材;分辨率: 按元素适配;支持九宫格缩放" if isSheet {
return "输出格式: UI瓦片集(tileset);规则网格排列;纯白色背景(#FFFFFF);元件间留8-16px纯白间隙;标注行列数;支持九宫格缩放;可脚本一键拆分"
}
return "输出格式: 独立PNG素材;纯白色背景(#FFFFFF);分辨率: 按元素适配;支持九宫格缩放"
case "animation": case "animation":
return "输出格式: spritesheet或帧序列;建议4方向x4帧;透明背景" if isSheet {
return "输出格式: 动画精灵表(spritesheet);帧间留8-16px纯白间隙(间隙内无任何像素),行/列间隙相等;纯白色背景(#FFFFFF,无渐变无噪点);标注行列数与方向数"
}
return "输出格式: 帧序列或帧条带;独立帧文件;纯白色背景(#FFFFFF);建议4方向x4帧"
default: default:
return "输出格式: PNG;分辨率: 标准2D游戏分辨率" return "输出格式: PNG;纯白色背景(#FFFFFF);分辨率: 标准2D游戏分辨率"
} }
} }
+87 -21
View File
@@ -31,14 +31,30 @@ func TestRunPromptAgent_Fallback(t *testing.T) {
func TestRunPromptAgent_Sprite(t *testing.T) { func TestRunPromptAgent_Sprite(t *testing.T) {
output, err := RunPromptAgent(context.Background(), PromptAgentInput{ output, err := RunPromptAgent(context.Background(), PromptAgentInput{
Tags: []string{"像素", "中世纪", "战士"}, Tags: []string{"像素", "中世纪", "战士", "精灵表"},
AssetType: "sprite", AssetType: "sprite",
}) })
if err != nil { if err != nil {
t.Fatalf("RunPromptAgent failed: %v", err) t.Fatalf("RunPromptAgent failed: %v", err)
} }
if !strings.Contains(output.Prompt, "spritesheet") { if !strings.Contains(output.Prompt, "spritesheet") {
t.Errorf("sprite output should mention spritesheet: %s", output.Prompt) t.Errorf("sprite sheet output should mention spritesheet: %s", output.Prompt)
}
}
func TestRunPromptAgent_SpriteSingle(t *testing.T) {
output, err := RunPromptAgent(context.Background(), PromptAgentInput{
Tags: []string{"像素", "中世纪", "战士"},
AssetType: "sprite",
})
if err != nil {
t.Fatalf("RunPromptAgent failed: %v", err)
}
if !strings.Contains(output.Prompt, "纯白色背景") {
t.Errorf("single sprite output should mention 纯白色背景: %s", output.Prompt)
}
if strings.Contains(output.Prompt, "spritesheet") {
t.Errorf("single sprite output should not mention spritesheet: %s", output.Prompt)
} }
} }
@@ -251,8 +267,9 @@ func TestBuildMetaPrompt_NoUserNote(t *testing.T) {
func TestParseTagsFromMeta(t *testing.T) { func TestParseTagsFromMeta(t *testing.T) {
meta := `用户选择标签: 像素、中世纪、战士 meta := `用户选择标签: 像素、中世纪、战士
素材类型: sprite` 素材类型: sprite
tags, assetType := parseTagsFromMeta(meta) 用户原始描述: 一个持剑角色`
tags, assetType, userPrompt := parseTagsFromMeta(meta)
if len(tags) != 3 { if len(tags) != 3 {
t.Fatalf("expected 3 tags, got %d: %v", len(tags), tags) t.Fatalf("expected 3 tags, got %d: %v", len(tags), tags)
} }
@@ -262,26 +279,29 @@ func TestParseTagsFromMeta(t *testing.T) {
if assetType != "sprite" { if assetType != "sprite" {
t.Errorf("expected assetType=sprite, got %s", assetType) t.Errorf("expected assetType=sprite, got %s", assetType)
} }
if userPrompt != "一个持剑角色" {
t.Errorf("expected userPrompt='一个持剑角色', got %s", userPrompt)
}
} }
func TestParseTagsFromMeta_SingleTag(t *testing.T) { func TestParseTagsFromMeta_SingleTag(t *testing.T) {
meta := `用户选择标签: 赛博朋克 meta := `用户选择标签: 赛博朋克
素材类型: background` 素材类型: background`
tags, _ := parseTagsFromMeta(meta) tags, _, _ := parseTagsFromMeta(meta)
if len(tags) != 1 || tags[0] != "赛博朋克" { if len(tags) != 1 || tags[0] != "赛博朋克" {
t.Errorf("expected [赛博朋克], got %v", tags) t.Errorf("expected [赛博朋克], got %v", tags)
} }
} }
func TestParseTagsFromMeta_Empty(t *testing.T) { func TestParseTagsFromMeta_Empty(t *testing.T) {
tags, assetType := parseTagsFromMeta("no tags here") tags, assetType, _ := parseTagsFromMeta("no tags here")
if len(tags) != 0 || assetType != "" { if len(tags) != 0 || assetType != "" {
t.Errorf("expected empty, got tags=%v assetType=%s", tags, assetType) t.Errorf("expected empty, got tags=%v assetType=%s", tags, assetType)
} }
} }
func TestGenerateStructuredPrompt(t *testing.T) { func TestGenerateStructuredPrompt(t *testing.T) {
prompt := generateStructuredPrompt([]string{"像素", "战士"}, "sprite") prompt := generateStructuredPrompt([]string{"像素", "战士", "精灵表"}, "sprite", true)
if !strings.HasPrefix(prompt, "【主题】") { if !strings.HasPrefix(prompt, "【主题】") {
t.Error("prompt should start with 【主题】") t.Error("prompt should start with 【主题】")
} }
@@ -293,21 +313,36 @@ func TestGenerateStructuredPrompt(t *testing.T) {
} }
} }
func TestGenerateStructuredPrompt_Single(t *testing.T) {
prompt := generateStructuredPrompt([]string{"像素", "战士"}, "sprite", false)
if !strings.Contains(prompt, "独立PNG") {
t.Errorf("single sprite should contain 独立PNG: %s", prompt)
}
if strings.Contains(prompt, "spritesheet") {
t.Errorf("single sprite should not contain spritesheet: %s", prompt)
}
}
func TestBuildSubject(t *testing.T) { func TestBuildSubject(t *testing.T) {
tags := []string{"像素", "战士"} tags := []string{"像素", "战士"}
tests := []struct { tests := []struct {
assetType, want string assetType, want string
isSheet bool
}{ }{
{"sprite", "精灵图"}, {"sprite", "精灵图", false},
{"background", "场景背景"}, {"sprite", "精灵表", true},
{"ui", "UI元素"}, {"background", "场景背景", false},
{"animation", "动画帧序列"}, {"background", "瓦片集", true},
{"unknown", "游戏素材"}, {"ui", "UI元素", false},
{"ui", "瓦片集", true},
{"animation", "动画帧序列", false},
{"animation", "精灵表", true},
{"unknown", "游戏素材", false},
} }
for _, tt := range tests { for _, tt := range tests {
result := buildSubject(tags, tt.assetType) result := buildSubject(tags, tt.assetType, tt.isSheet)
if !strings.Contains(result, tt.want) { if !strings.Contains(result, tt.want) {
t.Errorf("buildSubject(%q) = %s, want containing %q", tt.assetType, result, tt.want) t.Errorf("buildSubject(%q, isSheet=%v) = %s, want containing %q", tt.assetType, tt.isSheet, result, tt.want)
} }
} }
} }
@@ -327,17 +362,48 @@ func TestBuildStyle(t *testing.T) {
func TestBuildTechNotes(t *testing.T) { func TestBuildTechNotes(t *testing.T) {
tests := []struct { tests := []struct {
assetType, want string assetType, want string
isSheet bool
}{ }{
{"sprite", "spritesheet"}, // 默认单人模式
{"background", "1920x1080"}, {"sprite", "纯白色背景", false},
{"ui", "九宫格"}, {"background", "1920x1080", false},
{"animation", "4方向x4帧"}, {"ui", "九宫格", false},
{"unknown", "PNG"}, {"animation", "4方向x4帧", false},
{"unknown", "PNG", false},
// 瓦片集/精灵表模式
{"sprite", "spritesheet", true},
{"background", "tileset", true},
{"ui", "瓦片集", true},
{"animation", "spritesheet", true},
} }
for _, tt := range tests { for _, tt := range tests {
result := buildTechNotes(tt.assetType) result := buildTechNotes(tt.assetType, tt.isSheet)
if !strings.Contains(result, tt.want) { if !strings.Contains(result, tt.want) {
t.Errorf("buildTechNotes(%q) = %s, want containing %q", tt.assetType, result, tt.want) t.Errorf("buildTechNotes(%q, isSheet=%v) = %s, want containing %q", tt.assetType, tt.isSheet, result, tt.want)
}
}
}
func TestIsSheetRequest(t *testing.T) {
tests := []struct {
tags []string
prompt string
want bool
}{
{[]string{"像素", "精灵表"}, "", true},
{[]string{"像素", "spritesheet"}, "", true},
{[]string{"地形", "瓦片集"}, "", true},
{[]string{"UI", "tileset"}, "", true},
{[]string{"场景", "tilemap"}, "", true},
{[]string{"像素", "战士"}, "", false},
{[]string{"像素"}, "生成一个精灵表", true},
{[]string{"森林"}, "场景瓦片集", true},
{nil, "", false},
}
for _, tt := range tests {
got := isSheetRequest(tt.tags, tt.prompt)
if got != tt.want {
t.Errorf("isSheetRequest(tags=%v, prompt=%q) = %v, want %v", tt.tags, tt.prompt, got, tt.want)
} }
} }
} }
+5 -1
View File
@@ -36,11 +36,14 @@ 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 帧参数
type FrameParams struct { type FrameParams struct {
Directions int Directions int
FramesPerDirection int FramesPerDirection int
} }
@@ -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)
} }
+128
View File
@@ -0,0 +1,128 @@
// Package gifmaker encodes sprite animation frames into a GIF preview.
//
// Features:
// - Unified canvas: all frames normalized to the same dimensions
// - Transparent background: palette index 0 = fully transparent
// - DisposalBackground: each frame clears the previous one, no ghosting
//
// Pipeline integration:
//
// frames, _ := splitsprite.Process(img, splitsprite.DefaultOptions())
// gifmaker.Save("preview.gif", frames, nil)
package gifmaker
import (
"fmt"
"image"
"image/color"
"image/gif"
"io"
"os"
)
// Options configures GIF generation.
type Options struct {
// Delay is the frame delay in 1/100s (default 10).
Delay int
// MaxColors is the maximum palette size (default 256).
MaxColors int
}
// DefaultOptions returns sensible defaults.
func DefaultOptions() *Options {
return &Options{
Delay: 10,
MaxColors: 256,
}
}
// Save is a convenience wrapper that writes frames to a GIF file.
func Save(path string, frames []image.Image, opts *Options) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create gif file: %w", err)
}
defer f.Close()
return Encode(f, frames, opts)
}
// Encode writes an animated GIF to w. All frames are normalized to a unified
// canvas (max width/height across frames), the palette starts with a
// transparent color, and DisposalBackground prevents inter-frame ghosting.
func Encode(w io.Writer, frames []image.Image, opts *Options) error {
if len(frames) == 0 {
return fmt.Errorf("no frames to encode")
}
if opts == nil {
opts = DefaultOptions()
}
delay := opts.Delay
if delay <= 0 {
delay = 10
}
maxColors := opts.MaxColors
if maxColors <= 0 || maxColors > 256 {
maxColors = 256
}
// Unified canvas
maxW, maxH := 0, 0
for _, f := range frames {
b := f.Bounds()
if b.Dx() > maxW {
maxW = b.Dx()
}
if b.Dy() > maxH {
maxH = b.Dy()
}
}
// Palette with transparent at index 0
pal := color.Palette{color.RGBA{0, 0, 0, 0}}
seen := make(map[color.RGBA]bool)
for _, fr := range frames {
b := fr.Bounds()
for y := b.Min.Y; y < b.Max.Y; y += 3 {
for x := b.Min.X; x < b.Max.X; x += 3 {
r, g, bl, a := fr.At(x, y).RGBA()
c := color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(bl >> 8), uint8(a >> 8)}
if !seen[c] && len(pal) < maxColors-1 {
seen[c] = true
pal = append(pal, c)
}
}
}
}
anim := &gif.GIF{
Config: image.Config{Width: maxW, Height: maxH},
}
for _, frame := range frames {
pl := image.NewPaletted(image.Rect(0, 0, maxW, maxH), pal)
// Manually map pixels: transparent → index 0, colored → nearest palette
b := frame.Bounds()
for y := 0; y < maxH; y++ {
for x := 0; x < maxW; x++ {
sx := x + b.Min.X
sy := y + b.Min.Y
if sx < b.Max.X && sy < b.Max.Y {
r, g, bl, a := frame.At(sx, sy).RGBA()
if a > 0 {
c := color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(bl >> 8), uint8(a >> 8)}
pl.Set(x, y, c)
}
// else: stays at index 0 (transparent)
}
}
}
anim.Image = append(anim.Image, pl)
anim.Delay = append(anim.Delay, delay)
anim.Disposal = append(anim.Disposal, gif.DisposalBackground)
}
anim.LoopCount = 0
anim.BackgroundIndex = 0
return gif.EncodeAll(w, anim)
}
+282 -7
View File
@@ -1,6 +1,6 @@
// Package splitsprite provides PNG sprite sheet splitting utilities. // Package splitsprite provides PNG sprite sheet splitting utilities.
// //
// Pipeline: green screen removal → projection-based gap detection → // Pipeline: white/green background removal → projection-based gap detection →
// split into tiles → filter out low-fill tiles → trim transparent edges. // split into tiles → filter out low-fill tiles → trim transparent edges.
package splitsprite package splitsprite
@@ -15,11 +15,23 @@ import (
// Options configures the sprite sheet splitting pipeline. // Options configures the sprite sheet splitting pipeline.
type Options struct { type Options struct {
// WhiteBg enables white background removal.
WhiteBg bool
// WhiteThreshold is the max distance from pure white (0–255, default 40).
WhiteThreshold uint8
// GreenScreen enables green background removal. // GreenScreen enables green background removal.
GreenScreen bool GreenScreen bool
// GreenTolerance controls how aggressively green pixels are removed (0–1, default 0.2). // GreenTolerance controls how aggressively green pixels are removed (0–1, default 0.2).
GreenTolerance float64 GreenTolerance float64
// GridRows / GridCols enable fixed-grid splitting (overrides projection detection).
// When >0, the image is divided equally into Rows×Cols cells.
GridRows int
GridCols int
// GridPadding is the gap between cells in pixels (default 2).
GridPadding int
// GapThreshold is the max fraction of non-transparent pixels a row/column // GapThreshold is the max fraction of non-transparent pixels a row/column
// can have to be considered a gap (0–1, default 0.03). // can have to be considered a gap (0–1, default 0.03).
GapThreshold float64 GapThreshold float64
@@ -32,24 +44,42 @@ type Options struct {
// Trim removes transparent borders from output tiles. // Trim removes transparent borders from output tiles.
Trim bool Trim bool
// CenterAlign centers content across all frames so characters stay in place.
// All output frames get the same dimensions with content centered.
CenterAlign bool
// OutW / OutH specify the output tile size (0 = keep original). // OutW / OutH specify the output tile size (0 = keep original).
OutW, OutH int OutW, OutH int
} }
// DefaultOptions returns sensible default splitting options. // DefaultOptions returns sensible default splitting options (white background mode).
func DefaultOptions() *Options { func DefaultOptions() *Options {
return &Options{
WhiteBg: true,
WhiteThreshold: 40,
GapThreshold: 0.03,
MinGapWidth: 2,
MinFillRatio: 0.14,
Trim: true,
CenterAlign: true,
}
}
// DefaultGreenOptions returns options tuned for green screen sprite sheets.
func DefaultGreenOptions() *Options {
return &Options{ return &Options{
GreenScreen: true, GreenScreen: true,
GreenTolerance: 0.2, GreenTolerance: 0.2,
GapThreshold: 0.03, GapThreshold: 0.03,
MinGapWidth: 2, MinGapWidth: 2,
MinFillRatio: 0.3, MinFillRatio: 0.14,
Trim: true, Trim: true,
CenterAlign: true,
} }
} }
// Process splits a sprite sheet image into individual cleaned tile images. // Process splits a sprite sheet image into individual cleaned tile images.
// It runs the full pipeline: green screen removal → split → trim → resize. // It runs the full pipeline: background removal → split → trim → resize.
// When GridRows/GridCols > 0, fixed-grid splitting is used instead of projection detection.
func Process(img image.Image, opts *Options) ([]image.Image, error) { func Process(img image.Image, opts *Options) ([]image.Image, error) {
if opts == nil { if opts == nil {
opts = DefaultOptions() opts = DefaultOptions()
@@ -57,13 +87,20 @@ func Process(img image.Image, opts *Options) ([]image.Image, error) {
src := toRGBA(img) src := toRGBA(img)
if opts.GreenScreen { if opts.WhiteBg {
src = removeWhiteBg(src, opts.WhiteThreshold)
} else if opts.GreenScreen {
src = removeGreenScreen(src, opts.GreenTolerance) src = removeGreenScreen(src, opts.GreenTolerance)
} }
tiles := projectionSplit(src, opts.GapThreshold, opts.MinGapWidth, opts.MinFillRatio) var tiles []tile
if opts.GridRows > 0 && opts.GridCols > 0 {
tiles = fixedGridSplit(src, opts.GridRows, opts.GridCols, opts.GridPadding, opts.MinFillRatio)
} else {
tiles = projectionSplit(src, opts.GapThreshold, opts.MinGapWidth, opts.MinFillRatio)
}
if len(tiles) == 0 { if len(tiles) == 0 {
return nil, fmt.Errorf("no tiles detected — try lowering GapThreshold or adjusting GreenTolerance") return nil, fmt.Errorf("no tiles detected — try lowering GapThreshold or setting GridRows/GridCols")
} }
results := make([]image.Image, len(tiles)) results := make([]image.Image, len(tiles))
@@ -79,9 +116,102 @@ func Process(img image.Image, opts *Options) ([]image.Image, error) {
} }
results[i] = sub results[i] = sub
} }
if opts.CenterAlign && len(results) > 1 {
results = alignCenter(results)
}
return results, nil return results, nil
} }
// RemoveWhiteBg removes near-white background pixels, making them transparent.
func RemoveWhiteBg(img image.Image, threshold uint8) image.Image {
return removeWhiteBg(toRGBA(img), threshold)
}
// CenterFrames centers the content of each frame within a uniform canvas so
// characters stay in place across frames.
func CenterFrames(frames []image.Image) []image.Image {
return alignCenter(frames)
}
// alignCenter aligns all frames to a uniform canvas with a fixed reference point.
// Uses bottom-center alignment so characters share a common ground plane across frames,
// preventing drift/jitter in animation playback.
func alignCenter(frames []image.Image) []image.Image {
type contentBox struct {
minX, minY, maxX, maxY int
}
boxes := make([]contentBox, len(frames))
maxCW, maxCH := 0, 0
for i, f := range frames {
b := f.Bounds()
minX, minY := b.Max.X, b.Max.Y
maxX, maxY := b.Min.X, b.Min.Y
hasContent := false
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
_, _, _, a := f.At(x, y).RGBA()
if a > 0 {
hasContent = true
if x < minX {
minX = x
}
if x > maxX {
maxX = x
}
if y < minY {
minY = y
}
if y > maxY {
maxY = y
}
}
}
}
if !hasContent {
boxes[i] = contentBox{0, 0, b.Dx(), b.Dy()}
} else {
boxes[i] = contentBox{minX, minY, maxX, maxY}
}
cw := boxes[i].maxX - boxes[i].minX + 1
ch := boxes[i].maxY - boxes[i].minY + 1
if cw > maxCW {
maxCW = cw
}
if ch > maxCH {
maxCH = ch
}
}
// Uniform canvas with 10% padding
canvasW := maxCW * 11 / 10
canvasH := maxCH * 11 / 10
// Fixed X center reference: anchor all frames to the same horizontal center
fixedCenterX := canvasW / 2
out := make([]image.Image, len(frames))
for i, f := range frames {
cb := boxes[i]
cw := cb.maxX - cb.minX + 1
ch := cb.maxY - cb.minY + 1
// All frames share the same center-X and bottom-Y anchor
ox := fixedCenterX - cw/2 // consistent horizontal center
oy := canvasH - ch // bottom-align: feet planted at same Y
canvas := image.NewRGBA(image.Rect(0, 0, canvasW, canvasH))
draw.Draw(canvas,
image.Rect(ox, oy, ox+cw, oy+ch),
f,
image.Point{cb.minX, cb.minY},
draw.Src,
)
out[i] = canvas
}
return out
}
// RemoveGreenScreen removes green-dominant background pixels, making them transparent. // RemoveGreenScreen removes green-dominant background pixels, making them transparent.
func RemoveGreenScreen(img image.Image, tol float64) image.Image { func RemoveGreenScreen(img image.Image, tol float64) image.Image {
return removeGreenScreen(toRGBA(img), tol) return removeGreenScreen(toRGBA(img), tol)
@@ -105,6 +235,37 @@ type tile struct {
x, y, w, h int x, y, w, h int
} }
// removeWhiteBg removes pixels close to pure white (R,G,B all within threshold of 255).
func removeWhiteBg(rgba *image.RGBA, threshold uint8) *image.RGBA {
if threshold == 0 {
threshold = 40
}
b := rgba.Bounds()
dst := image.NewRGBA(b)
draw.Draw(dst, b, rgba, b.Min, draw.Src)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
r, g, bl, a := rgba.At(x, y).RGBA()
if a == 0 {
continue
}
r8, g8, b8 := uint8(r>>8), uint8(g>>8), uint8(bl>>8)
// Distance from pure white
dist := max(int(255-r8), max(int(255-g8), int(255-b8)))
if dist < int(threshold)/2 {
// Very close to white — fully transparent
dst.SetRGBA(x, y, color.RGBA{R: r8, G: g8, B: b8, A: 0})
} else if dist < int(threshold) {
// Semi-white — fade alpha
alpha := float64(dist-int(threshold)/2) / float64(int(threshold)/2)
dst.SetRGBA(x, y, color.RGBA{R: r8, G: g8, B: b8, A: uint8(alpha * 255)})
}
}
}
return dst
}
func removeGreenScreen(rgba *image.RGBA, tol float64) *image.RGBA { func removeGreenScreen(rgba *image.RGBA, tol float64) *image.RGBA {
b := rgba.Bounds() b := rgba.Bounds()
dst := image.NewRGBA(b) dst := image.NewRGBA(b)
@@ -132,6 +293,36 @@ func removeGreenScreen(rgba *image.RGBA, tol float64) *image.RGBA {
return dst return dst
} }
// fixedGridSplit divides the image into Rows×Cols equally-sized cells,
// accounting for a fixed padding between cells.
func fixedGridSplit(rgba *image.RGBA, rows, cols, padding int, minFill float64) []tile {
b := rgba.Bounds()
W, H := b.Dx(), b.Dy()
if padding < 0 {
padding = 0
}
totalPadW := padding * (cols + 1)
totalPadH := padding * (rows + 1)
cellW := (W - totalPadW) / cols
cellH := (H - totalPadH) / rows
if cellW <= 0 || cellH <= 0 {
return nil
}
var tiles []tile
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
x := padding + c*(cellW+padding)
y := padding + r*(cellH+padding)
if tileFillRatio(rgba, x, y, cellW, cellH) >= minFill {
tiles = append(tiles, tile{x: x, y: y, w: cellW, h: cellH})
}
}
}
return tiles
}
func projectionSplit(rgba *image.RGBA, gapThreshold float64, minGap int, minFill float64) []tile { func projectionSplit(rgba *image.RGBA, gapThreshold float64, minGap int, minFill float64) []tile {
bounds := rgba.Bounds() bounds := rgba.Bounds()
W, H := bounds.Dx(), bounds.Dy() W, H := bounds.Dx(), bounds.Dy()
@@ -196,6 +387,90 @@ func tileFillRatio(rgba *image.RGBA, x0, y0, w, h int) float64 {
} }
func findCuts(ratios []float64, threshold float64, minGap int) []int { func findCuts(ratios []float64, threshold float64, minGap int) []int {
n := len(ratios)
if n == 0 {
return nil
}
// Smooth the ratio curve with a moving average (kernel size = minGap)
smoothed := make([]float64, n)
kernel := max(minGap, 3)
for i := 0; i < n; i++ {
sum := 0.0
count := 0
for j := max(0, i-kernel/2); j < min(n, i+kernel/2+1); j++ {
sum += ratios[j]
count++
}
if count > 0 {
smoothed[i] = sum / float64(count)
}
}
// Compute mean to use as reference
mean := 0.0
for _, r := range smoothed {
mean += r
}
mean /= float64(n)
// Find peaks: contiguous regions where smoothed ratio > mean*1.2
type segment struct{ start, end int }
var peaks []segment
i := 0
for i < n {
if smoothed[i] > mean*1.2 {
start := i
for i < n && smoothed[i] > mean*0.8 {
i++
}
peaks = append(peaks, segment{start, i})
} else {
i++
}
}
if len(peaks) < 2 {
// Fallback: use threshold-based gap detection
return findCutsByGap(ratios, threshold, minGap)
}
// Find valleys between adjacent peaks (minimum smoothed ratio between them)
cuts := []int{0}
for p := 0; p < len(peaks)-1; p++ {
valleyStart := peaks[p].end
valleyEnd := peaks[p+1].start
if valleyStart >= valleyEnd {
// Peaks adjacent — cut at midpoint
cuts = append(cuts, (peaks[p].end+peaks[p+1].start)/2)
continue
}
// Find minimum in the valley region
minIdx := valleyStart
minVal := smoothed[valleyStart]
for j := valleyStart + 1; j < valleyEnd; j++ {
if smoothed[j] < minVal {
minVal = smoothed[j]
minIdx = j
}
}
cuts = append(cuts, minIdx)
}
cuts = append(cuts, n)
sort.Ints(cuts)
// Deduplicate
dedup := cuts[:1]
for j := 1; j < len(cuts); j++ {
if cuts[j] != dedup[len(dedup)-1] {
dedup = append(dedup, cuts[j])
}
}
return dedup
}
// findCutsByGap is the original threshold-based fallback.
func findCutsByGap(ratios []float64, threshold float64, minGap int) []int {
n := len(ratios) n := len(ratios)
isGap := make([]bool, n) isGap := make([]bool, n)
for i, r := range ratios { for i, r := range ratios {
+121
View File
@@ -0,0 +1,121 @@
//go:build ignore
package main
import (
"bytes"
"context"
"fmt"
"image"
"image/png"
"os"
"gen2d/internal/config"
"gen2d/internal/service"
"gen2d/pkg/gifmaker"
"gen2d/pkg/splitsprite"
)
func main() {
cfg := config.Load()
service.InitLLMConfig(cfg.LLM)
service.InitImageGenConfig(cfg.ImageGen)
ctx := context.Background()
// 1. PromptAgent 优化提示词
fmt.Println("=== Step 1: Optimize prompt via PromptAgent ===")
agentIn := service.PromptAgentInput{
Tags: []string{"像素", "横版动作", "大剑战士", "精灵表", "4x4网格"},
AssetType: "sprite",
Prompt: "生成一个2D横版动作游戏角色的连续攻击连招精灵表,侧视角,4行4列网格排列",
UserNote: "角色为持大剑的战士,连招包含4段攻击:横斩→上挑→跳劈→终结重击。每段攻击4帧关键帧,共16帧,按4行×4列网格排列。帧间留8-16px纯白间隙(#FFFFFF)。要求动作流畅有力量感,大剑挥舞轨迹清晰",
}
out, err := service.RunPromptAgent(ctx, agentIn)
if err != nil {
fatalf("PromptAgent failed: %v", err)
}
fmt.Printf("Optimized prompt:\n%s\n\n", out.Prompt)
// 2. 调用文生图 API
fmt.Println("=== Step 2: Generate sprite sheet via image API ===")
params := service.AssetParams{
Resolution: 1536,
Format: "spritesheet",
}
images, err := service.GenerateImages(ctx, out.Prompt, params)
if err != nil {
fatalf("GenerateImages failed: %v", err)
}
if len(images) == 0 {
fatalf("no images generated")
}
fmt.Printf("Generated %d image(s), size=%dx%d\n", len(images), images[0].Width, images[0].Height)
os.MkdirAll("test_output", 0755)
// 保存原始精灵表
sheetPath := "test_output/sprite_sheet.png"
if err := os.WriteFile(sheetPath, images[0].Data, 0644); err != nil {
fatalf("save sheet: %v", err)
}
fmt.Printf("Saved sprite sheet → %s (%d bytes)\n", sheetPath, len(images[0].Data))
// 3. splitsprite: 洗白底 → 波谷投影拆分 → 有效像素≥40% → 裁切像素边界 → 统一最大分辨率居中
fmt.Println("\n=== Step 3: Wash white bg → valley projection split → trim → center align ===")
sheetImg, err := decodePNG(images[0].Data)
if err != nil {
fatalf("decode sheet: %v", err)
}
opts := splitsprite.DefaultOptions() // WhiteBg=true, MinFillRatio=0.4, CenterAlign=true
frames, err := splitsprite.Process(sheetImg, opts)
if err != nil {
fatalf("split failed: %v", err)
}
fmt.Printf("Detected %d frames\n", len(frames))
// 保存单帧
for i, f := range frames {
fn := fmt.Sprintf("test_output/frame_%03d.png", i)
if err := savePNG(fn, f); err != nil {
fatalf("save frame %d: %v", i, err)
}
}
fmt.Printf("Saved %d frames → test_output/frame_*.png\n", len(frames))
// 4. 生成 GIF 预览
fmt.Println("\n=== Step 4: Generate GIF preview ===")
gifPath := "test_output/preview.gif"
if err := gifmaker.Save(gifPath, frames, nil); err != nil {
fatalf("generate GIF: %v", err)
}
fmt.Printf("GIF preview → %s (%d frames)\n", gifPath, len(frames))
fmt.Println("\n=== Done ===")
fmt.Println("Output files:")
fmt.Println(" test_output/sprite_sheet.png — original sprite sheet")
fmt.Println(" test_output/frame_*.png — individual frames")
fmt.Println(" test_output/preview.gif — animated GIF preview")
}
func decodePNG(data []byte) (image.Image, error) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
return img, nil
}
func savePNG(path string, img image.Image) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return png.Encode(f, img)
}
func fatalf(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}