From aff48b678066e062d8402027e6349d1e23ae7183 Mon Sep 17 00:00:00 2001 From: Gmaker689 <1711322114@qq.com> Date: Mon, 25 May 2026 16:23:00 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=94=AF=E6=8C=81=E5=A4=9A=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E8=AF=86=E5=88=AB=E4=B8=8E=E7=BA=AF=E7=99=BD?= =?UTF-8?q?=E8=83=8C=E6=99=AF=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildMetaPrompt 按素材类型给出双模式指令(单素材/网格瓦片集),LLM 根据用户意图选择 - 新增 isSheetRequest 自动识别精灵表/瓦片集/tileset 关键字 - 模板回退链路全面支持 isSheet 双模式,默认单素材模式 - 所有素材类型统一纯白色背景(#FFFFFF),由后期 format 节点清洗去背 - 补充 sprite/background/ui/animation 四类素材的完整生成场景覆盖 --- backend/internal/service/prompt_agent.go | 92 ++++++++++++--- backend/internal/service/prompt_agent_test.go | 108 ++++++++++++++---- 2 files changed, 165 insertions(+), 35 deletions(-) diff --git a/backend/internal/service/prompt_agent.go b/backend/internal/service/prompt_agent.go index f3e64ce..19a5270 100755 --- a/backend/internal/service/prompt_agent.go +++ b/backend/internal/service/prompt_agent.go @@ -26,7 +26,7 @@ func InitLLMConfig(cfg config.LLMConfig) { // PromptAgentInput 提示词优化 Agent 输入。 type PromptAgentInput struct { - Tags []string `json:"tags"` // 用户选择的标签 + Tags []string `json:"tags"` // 用户选择的标签 AssetType string `json:"assetType"` // 素材类型:sprite/background/ui/animation Prompt string `json:"prompt,omitempty"` // 用户原始提示词 UserNote string `json:"userNote,omitempty"` // 用户额外描述 @@ -89,7 +89,23 @@ func buildMetaPrompt(in PromptAgentInput) string { sb.WriteString("输出要求:\n") sb.WriteString("1. 三段式结构:【主题】描述画面主体与场景,【风格】描述艺术风格与视觉特征,【技术】描述分辨率、方向数等技术参数\n") sb.WriteString("2. 使用专业术语,描述具体、可执行\n") - sb.WriteString("3. 风格一致,适合游戏资产管线\n\n") + sb.WriteString("3. 风格一致,适合游戏资产管线\n") + sb.WriteString("4. 根据用户标签和描述,识别素材布局模式,在【技术】段明确标注格式:\n") + switch in.AssetType { + case "sprite": + sb.WriteString(" - 单个精灵(默认):独立PNG,纯白色背景(#FFFFFF),描述为单个角色立绘/道具图标\n") + sb.WriteString(" - 精灵表(spritesheet):角色/道具按行列等距网格排列,纯白色背景(#FFFFFF),帧间固定间距(2-4px),标注行列数,便于脚本一键拆分\n") + case "background": + sb.WriteString(" - 独立场景(默认):单张完整背景图,层次分明\n") + sb.WriteString(" - 场景瓦片集(tileset):地形/建筑元件按规则网格排列,纯白色背景(#FFFFFF),元件间固定间距,标注行列数与瓦片尺寸,确保无缝拼接\n") + case "ui": + sb.WriteString(" - 独立UI元素(默认):单个按钮/面板/图标,纯白色背景(#FFFFFF),独立PNG\n") + sb.WriteString(" - UI瓦片集(tileset):UI元件按规则网格排列,纯白色背景(#FFFFFF),元件间固定间距,标注行列数,支持九宫格缩放,便于脚本一键拆分\n") + case "animation": + sb.WriteString(" - 帧序列(默认):连续动画帧,独立帧文件或帧条带,纯白色背景(#FFFFFF)\n") + sb.WriteString(" - 动画精灵表(spritesheet):动画帧按行列等距网格排列,纯白色背景(#FFFFFF),帧间固定间距,标注行列数与方向数,便于脚本一键拆分\n") + } + sb.WriteString("\n\n") tagStr := strings.Join(in.Tags, "、") sb.WriteString(fmt.Sprintf("用户选择标签: %s\n", tagStr)) @@ -242,12 +258,13 @@ func parseStreamResponse(r io.Reader) (string, error) { // ======================== 模板回退 ======================== func fallbackRefine(metaPrompt string) PromptAgentOutput { - tags, assetType := parseTagsFromMeta(metaPrompt) - prompt := generateStructuredPrompt(tags, assetType) + tags, assetType, userPrompt := parseTagsFromMeta(metaPrompt) + isSheet := isSheetRequest(tags, userPrompt) + prompt := generateStructuredPrompt(tags, assetType, isSheet) 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") for _, line := range lines { if strings.HasPrefix(line, "用户选择标签:") { @@ -262,34 +279,69 @@ func parseTagsFromMeta(meta string) (tags []string, assetType string) { if strings.HasPrefix(line, "素材类型:") { assetType = strings.TrimSpace(strings.TrimPrefix(line, "素材类型: ")) } + if strings.HasPrefix(line, "用户原始描述:") { + userPrompt = strings.TrimSpace(strings.TrimPrefix(line, "用户原始描述: ")) + } } 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 sb.WriteString("【主题】") - sb.WriteString(buildSubject(tags, assetType)) + sb.WriteString(buildSubject(tags, assetType, isSheet)) sb.WriteString("\n") sb.WriteString("【风格】") sb.WriteString(buildStyle(tags)) sb.WriteString("\n") sb.WriteString("【技术】") - sb.WriteString(buildTechNotes(assetType)) + sb.WriteString(buildTechNotes(assetType, isSheet)) sb.WriteString("\n") return sb.String() } -func buildSubject(tags []string, assetType string) string { +func buildSubject(tags []string, assetType string, isSheet bool) string { tagStr := strings.Join(tags, "、") switch assetType { case "sprite": + if isSheet { + return fmt.Sprintf("一个融合%s元素的游戏角色精灵表,角色按行列等距网格排列,轮廓清晰,适合作为2D游戏角色", tagStr) + } return fmt.Sprintf("一个融合%s元素的游戏角色精灵图,正面站立姿势,轮廓清晰,适合作为2D游戏角色", tagStr) case "background": + if isSheet { + return fmt.Sprintf("一套%s风格的游戏场景瓦片集,地形/建筑元件按规则网格排列,适合2D游戏地图拼接", tagStr) + } return fmt.Sprintf("一个%s风格的游戏场景背景,层次分明,包含前景、中景和远景", tagStr) case "ui": + if isSheet { + return fmt.Sprintf("一套%s风格的UI瓦片集,UI元件按规则网格排列,适合脚本一键拆分", tagStr) + } return fmt.Sprintf("一套%s风格的游戏UI元素,包括按钮、面板和图标", tagStr) case "animation": + if isSheet { + return fmt.Sprintf("一个%s风格的角色动画精灵表,动画帧按行列等距网格排列,动作流畅连贯", tagStr) + } return fmt.Sprintf("一个%s风格的角色动画帧序列,动作流畅连贯", tagStr) default: return fmt.Sprintf("一个%s风格的游戏素材,高质量,适合2D游戏使用", tagStr) @@ -304,17 +356,29 @@ func buildStyle(tags []string) string { return strings.Join(parts, ";") } -func buildTechNotes(assetType string) string { +func buildTechNotes(assetType string, isSheet bool) string { switch assetType { case "sprite": - return "输出格式: spritesheet;分辨率: 64x64 或 128x128;透明背景" + if isSheet { + return "输出格式: spritesheet;行列间隔完全相等、可脚本一键拆分对齐;纯白色背景(#FFFFFF,无渐变无噪点);帧间固定间距(2-4px);标注行列数" + } + return "输出格式: 独立PNG;纯白色背景(#FFFFFF);尺寸: 按角色比例适配" case "background": + if isSheet { + return "输出格式: 场景瓦片集(tileset);规则网格排列;纯白色背景(#FFFFFF);瓦片间固定间距;标注行列数与瓦片尺寸;确保无缝拼接" + } return "输出格式: 独立PNG;分辨率: 1920x1080;层次分明的前中后景" case "ui": - return "输出格式: 独立PNG素材;分辨率: 按元素适配;支持九宫格缩放" + if isSheet { + return "输出格式: UI瓦片集(tileset);规则网格排列;纯白色背景(#FFFFFF);元件间固定间距;标注行列数;支持九宫格缩放;可脚本一键拆分" + } + return "输出格式: 独立PNG素材;纯白色背景(#FFFFFF);分辨率: 按元素适配;支持九宫格缩放" case "animation": - return "输出格式: spritesheet或帧序列;建议4方向x4帧;透明背景" + if isSheet { + return "输出格式: 动画精灵表(spritesheet);行列间隔相等;纯白色背景(#FFFFFF,无渐变无噪点);帧间固定间距;标注行列数与方向数;可脚本一键拆分" + } + return "输出格式: 帧序列或帧条带;独立帧文件;纯白色背景(#FFFFFF);建议4方向x4帧" default: - return "输出格式: PNG;分辨率: 标准2D游戏分辨率" + return "输出格式: PNG;纯白色背景(#FFFFFF);分辨率: 标准2D游戏分辨率" } } diff --git a/backend/internal/service/prompt_agent_test.go b/backend/internal/service/prompt_agent_test.go index f805286..b0052c9 100755 --- a/backend/internal/service/prompt_agent_test.go +++ b/backend/internal/service/prompt_agent_test.go @@ -31,14 +31,30 @@ func TestRunPromptAgent_Fallback(t *testing.T) { func TestRunPromptAgent_Sprite(t *testing.T) { output, err := RunPromptAgent(context.Background(), PromptAgentInput{ - Tags: []string{"像素", "中世纪", "战士"}, + Tags: []string{"像素", "中世纪", "战士", "精灵表"}, AssetType: "sprite", }) if err != nil { t.Fatalf("RunPromptAgent failed: %v", err) } 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) { meta := `用户选择标签: 像素、中世纪、战士 -素材类型: sprite` - tags, assetType := parseTagsFromMeta(meta) +素材类型: sprite +用户原始描述: 一个持剑角色` + tags, assetType, userPrompt := parseTagsFromMeta(meta) if len(tags) != 3 { t.Fatalf("expected 3 tags, got %d: %v", len(tags), tags) } @@ -262,26 +279,29 @@ func TestParseTagsFromMeta(t *testing.T) { if assetType != "sprite" { t.Errorf("expected assetType=sprite, got %s", assetType) } + if userPrompt != "一个持剑角色" { + t.Errorf("expected userPrompt='一个持剑角色', got %s", userPrompt) + } } func TestParseTagsFromMeta_SingleTag(t *testing.T) { meta := `用户选择标签: 赛博朋克 素材类型: background` - tags, _ := parseTagsFromMeta(meta) + tags, _, _ := parseTagsFromMeta(meta) if len(tags) != 1 || tags[0] != "赛博朋克" { t.Errorf("expected [赛博朋克], got %v", tags) } } func TestParseTagsFromMeta_Empty(t *testing.T) { - tags, assetType := parseTagsFromMeta("no tags here") + tags, assetType, _ := parseTagsFromMeta("no tags here") if len(tags) != 0 || assetType != "" { t.Errorf("expected empty, got tags=%v assetType=%s", tags, assetType) } } func TestGenerateStructuredPrompt(t *testing.T) { - prompt := generateStructuredPrompt([]string{"像素", "战士"}, "sprite") + prompt := generateStructuredPrompt([]string{"像素", "战士", "精灵表"}, "sprite", true) if !strings.HasPrefix(prompt, "【主题】") { 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) { tags := []string{"像素", "战士"} tests := []struct { assetType, want string + isSheet bool }{ - {"sprite", "精灵图"}, - {"background", "场景背景"}, - {"ui", "UI元素"}, - {"animation", "动画帧序列"}, - {"unknown", "游戏素材"}, + {"sprite", "精灵图", false}, + {"sprite", "精灵表", true}, + {"background", "场景背景", false}, + {"background", "瓦片集", true}, + {"ui", "UI元素", false}, + {"ui", "瓦片集", true}, + {"animation", "动画帧序列", false}, + {"animation", "精灵表", true}, + {"unknown", "游戏素材", false}, } for _, tt := range tests { - result := buildSubject(tags, tt.assetType) + result := buildSubject(tags, tt.assetType, tt.isSheet) 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) { tests := []struct { assetType, want string + isSheet bool }{ - {"sprite", "spritesheet"}, - {"background", "1920x1080"}, - {"ui", "九宫格"}, - {"animation", "4方向x4帧"}, - {"unknown", "PNG"}, + // 默认单人模式 + {"sprite", "纯白色背景", false}, + {"background", "1920x1080", false}, + {"ui", "九宫格", false}, + {"animation", "4方向x4帧", false}, + {"unknown", "PNG", false}, + // 瓦片集/精灵表模式 + {"sprite", "spritesheet", true}, + {"background", "tileset", true}, + {"ui", "瓦片集", true}, + {"animation", "spritesheet", true}, } for _, tt := range tests { - result := buildTechNotes(tt.assetType) + result := buildTechNotes(tt.assetType, tt.isSheet) 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) } } } From f3dfcd3b0b46e7cfee7b89a7bff040afddec355a Mon Sep 17 00:00:00 2001 From: Gmaker689 <1711322114@qq.com> Date: Mon, 25 May 2026 16:48:48 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20splitsprite=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=99=BD=E5=BA=95=E7=A7=BB=E9=99=A4=E3=80=81=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E7=BD=91=E6=A0=BC=E6=8B=86=E5=88=86=E4=B8=8E=E5=B8=A7=E5=B1=85?= =?UTF-8?q?=E4=B8=AD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RemoveWhiteBg:纯白色背景(#FFFFFF)像素半透明化 - 新增 fixedGridSplit:GridRows×GridCols 固定网格拆分 - 新增 CenterAlign:帧内容居中对齐,确保人物不跳帧 - Options 新增 WhiteBg/GridRows/GridCols/GridPadding/CenterAlign - 默认模式从绿幕切换为白底 - 新增 tools/test_prompt_to_gif.go 端到端测试脚本 - .gitignore 忽略 test_output 目录 --- .gitignore | 1 + backend/pkg/splitsprite/splitsprite.go | 194 ++++++++++++++++++++++++- backend/tools/test_prompt_to_gif.go | 184 +++++++++++++++++++++++ 3 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 backend/tools/test_prompt_to_gif.go diff --git a/.gitignore b/.gitignore index bc4fa7d..90d4f7d 100755 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ backend/main # Generated output generation/ +backend/test_output/ diff --git a/backend/pkg/splitsprite/splitsprite.go b/backend/pkg/splitsprite/splitsprite.go index d63c5a4..78be163 100755 --- a/backend/pkg/splitsprite/splitsprite.go +++ b/backend/pkg/splitsprite/splitsprite.go @@ -1,6 +1,6 @@ // 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. package splitsprite @@ -15,11 +15,23 @@ import ( // Options configures the sprite sheet splitting pipeline. 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 bool // GreenTolerance controls how aggressively green pixels are removed (0–1, default 0.2). 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 // can have to be considered a gap (0–1, default 0.03). GapThreshold float64 @@ -32,12 +44,27 @@ type Options struct { // Trim removes transparent borders from output tiles. 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 int } -// DefaultOptions returns sensible default splitting options. +// DefaultOptions returns sensible default splitting options (white background mode). func DefaultOptions() *Options { + return &Options{ + WhiteBg: true, + WhiteThreshold: 40, + GapThreshold: 0.03, + MinGapWidth: 2, + MinFillRatio: 0.3, + Trim: true, + } +} + +// DefaultGreenOptions returns options tuned for green screen sprite sheets. +func DefaultGreenOptions() *Options { return &Options{ GreenScreen: true, GreenTolerance: 0.2, @@ -49,7 +76,8 @@ func DefaultOptions() *Options { } // 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) { if opts == nil { opts = DefaultOptions() @@ -57,13 +85,20 @@ func Process(img image.Image, opts *Options) ([]image.Image, error) { src := toRGBA(img) - if opts.GreenScreen { + if opts.WhiteBg { + src = removeWhiteBg(src, opts.WhiteThreshold) + } else if opts.GreenScreen { 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 { - 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)) @@ -79,9 +114,98 @@ func Process(img image.Image, opts *Options) ([]image.Image, error) { } results[i] = sub } + + if opts.CenterAlign && len(results) > 1 { + results = alignCenter(results) + } + 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 finds the content bounding box per frame, computes the max +// dimensions, then pads each frame so content is centered uniformly. +func alignCenter(frames []image.Image) []image.Image { + type contentBox struct { + minX, minY, maxX, maxY int + } + boxes := make([]contentBox, len(frames)) + maxW, maxH := 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} + } + w := boxes[i].maxX - boxes[i].minX + 1 + h := boxes[i].maxY - boxes[i].minY + 1 + if w > maxW { + maxW = w + } + if h > maxH { + maxH = h + } + } + + // Pad by 10% to avoid edge cropping + maxW = maxW * 11 / 10 + maxH = maxH * 11 / 10 + + 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 + ox := (maxW - cw) / 2 + oy := (maxH - ch) / 2 + + canvas := image.NewRGBA(image.Rect(0, 0, maxW, maxH)) + 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. func RemoveGreenScreen(img image.Image, tol float64) image.Image { return removeGreenScreen(toRGBA(img), tol) @@ -105,6 +229,34 @@ type tile struct { x, y, w, h int } +// removeWhiteBg removes pixels close to pure white (R,G,B all above threshold). +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) + // Pixel is "white" when all channels are near 255 + if int(255-r8) < int(threshold) && int(255-g8) < int(threshold) && int(255-b8) < int(threshold) { + // Calculate alpha: closer to white = more transparent + dist := max(int(255-r8), max(int(255-g8), int(255-b8))) + alpha := float64(dist) / float64(threshold) + 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 { b := rgba.Bounds() dst := image.NewRGBA(b) @@ -132,6 +284,36 @@ func removeGreenScreen(rgba *image.RGBA, tol float64) *image.RGBA { 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 { bounds := rgba.Bounds() W, H := bounds.Dx(), bounds.Dy() diff --git a/backend/tools/test_prompt_to_gif.go b/backend/tools/test_prompt_to_gif.go new file mode 100644 index 0000000..66decfa --- /dev/null +++ b/backend/tools/test_prompt_to_gif.go @@ -0,0 +1,184 @@ +//go:build ignore + +package main + +import ( + "bytes" + "context" + "fmt" + "image" + "image/color" + "image/draw" + "image/gif" + "image/png" + "os" + + "gen2d/internal/config" + "gen2d/internal/service" + "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{"像素", "战士", "持剑", "精灵表"}, + AssetType: "sprite", + Prompt: "生成一个像素风持剑战士的4方向行走精灵表", + UserNote: "需要4方向(上下左右),每方向4帧行走动画", + } + 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: 1024, + 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 拆分精灵表 + fmt.Println("\n=== Step 3: Split sprite sheet ===") + sheetImg, err := decodePNG(images[0].Data) + if err != nil { + fatalf("decode sheet: %v", err) + } + opts := splitsprite.DefaultOptions() + opts.GridRows = 4 + opts.GridCols = 4 + opts.GridPadding = 2 + opts.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 := genGIF(gifPath, frames, 12); 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 genGIF(path string, frames []image.Image, delay int) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + pal := buildPalette(frames) + anim := &gif.GIF{} + for _, frame := range frames { + b := frame.Bounds() + paletted := image.NewPaletted(b, pal) + draw.Draw(paletted, b, frame, b.Min, draw.Src) + anim.Image = append(anim.Image, paletted) + anim.Delay = append(anim.Delay, delay) + } + anim.LoopCount = 0 // loop forever + return gif.EncodeAll(f, anim) +} + +func buildPalette(frames []image.Image) color.Palette { + hist := make(map[color.RGBA]int) + sampleStep := max(1, len(frames)/8) + for i := 0; i < len(frames); i += sampleStep { + b := frames[i].Bounds() + step := max(1, (b.Dx()*b.Dy())/4096) + n := 0 + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + if n%step != 0 { + n++ + continue + } + n++ + r, g, bl, a := frames[i].At(x, y).RGBA() + if a > 0 { + c := color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(bl >> 8), A: uint8(a >> 8)} + hist[c]++ + } + } + } + } + pal := make(color.Palette, 0, 256) + for c := range hist { + pal = append(pal, c) + if len(pal) >= 240 { + break + } + } + pal = append(pal, + color.RGBA{0, 0, 0, 0}, + color.RGBA{0, 0, 0, 255}, + color.RGBA{255, 255, 255, 255}, + ) + return pal +} + +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) +} From 8309d32d4b2dd0906542cb3ef6a5410a47945443 Mon Sep 17 00:00:00 2001 From: Gmaker689 <1711322114@qq.com> Date: Mon, 25 May 2026 17:57:01 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E6=8A=95=E5=BD=B1=E6=B3=95?= =?UTF-8?q?=E6=B3=A2=E8=B0=B7=E6=A3=80=E6=B5=8B+=E7=99=BD=E5=BA=95?= =?UTF-8?q?=E7=A1=AC=E5=88=87=E6=96=AD+=E5=B8=A7=E5=BA=95=E9=83=A8?= =?UTF-8?q?=E5=AF=B9=E9=BD=90+gifmaker=E9=80=8F=E6=98=8EGIF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prompt_agent: 精灵表/瓦片集间隙从2-4px放宽到8-16px纯白 - splitsprite removeWhiteBg: dist 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) +} diff --git a/backend/pkg/splitsprite/splitsprite.go b/backend/pkg/splitsprite/splitsprite.go index 78be163..3166b34 100755 --- a/backend/pkg/splitsprite/splitsprite.go +++ b/backend/pkg/splitsprite/splitsprite.go @@ -58,8 +58,9 @@ func DefaultOptions() *Options { WhiteThreshold: 40, GapThreshold: 0.03, MinGapWidth: 2, - MinFillRatio: 0.3, + MinFillRatio: 0.14, Trim: true, + CenterAlign: true, } } @@ -70,8 +71,9 @@ func DefaultGreenOptions() *Options { GreenTolerance: 0.2, GapThreshold: 0.03, MinGapWidth: 2, - MinFillRatio: 0.3, + MinFillRatio: 0.14, Trim: true, + CenterAlign: true, } } @@ -133,14 +135,15 @@ func CenterFrames(frames []image.Image) []image.Image { return alignCenter(frames) } -// alignCenter finds the content bounding box per frame, computes the max -// dimensions, then pads each frame so content is centered uniformly. +// 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)) - maxW, maxH := 0, 0 + maxCW, maxCH := 0, 0 for i, f := range frames { b := f.Bounds() @@ -172,29 +175,32 @@ func alignCenter(frames []image.Image) []image.Image { } else { boxes[i] = contentBox{minX, minY, maxX, maxY} } - w := boxes[i].maxX - boxes[i].minX + 1 - h := boxes[i].maxY - boxes[i].minY + 1 - if w > maxW { - maxW = w + cw := boxes[i].maxX - boxes[i].minX + 1 + ch := boxes[i].maxY - boxes[i].minY + 1 + if cw > maxCW { + maxCW = cw } - if h > maxH { - maxH = h + if ch > maxCH { + maxCH = ch } } - // Pad by 10% to avoid edge cropping - maxW = maxW * 11 / 10 - maxH = maxH * 11 / 10 + // 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 - ox := (maxW - cw) / 2 - oy := (maxH - ch) / 2 + // 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, maxW, maxH)) + canvas := image.NewRGBA(image.Rect(0, 0, canvasW, canvasH)) draw.Draw(canvas, image.Rect(ox, oy, ox+cw, oy+ch), f, @@ -229,7 +235,7 @@ type tile struct { x, y, w, h int } -// removeWhiteBg removes pixels close to pure white (R,G,B all above threshold). +// 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 @@ -245,11 +251,14 @@ func removeWhiteBg(rgba *image.RGBA, threshold uint8) *image.RGBA { continue } r8, g8, b8 := uint8(r>>8), uint8(g>>8), uint8(bl>>8) - // Pixel is "white" when all channels are near 255 - if int(255-r8) < int(threshold) && int(255-g8) < int(threshold) && int(255-b8) < int(threshold) { - // Calculate alpha: closer to white = more transparent - dist := max(int(255-r8), max(int(255-g8), int(255-b8))) - alpha := float64(dist) / float64(threshold) + // 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)}) } } @@ -378,6 +387,90 @@ func tileFillRatio(rgba *image.RGBA, x0, y0, w, h int) float64 { } 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) isGap := make([]bool, n) for i, r := range ratios { diff --git a/backend/tools/test_prompt_to_gif.go b/backend/tools/test_prompt_to_gif.go index 66decfa..3b9d3b4 100644 --- a/backend/tools/test_prompt_to_gif.go +++ b/backend/tools/test_prompt_to_gif.go @@ -7,14 +7,12 @@ import ( "context" "fmt" "image" - "image/color" - "image/draw" - "image/gif" "image/png" "os" "gen2d/internal/config" "gen2d/internal/service" + "gen2d/pkg/gifmaker" "gen2d/pkg/splitsprite" ) @@ -28,10 +26,10 @@ func main() { // 1. PromptAgent 优化提示词 fmt.Println("=== Step 1: Optimize prompt via PromptAgent ===") agentIn := service.PromptAgentInput{ - Tags: []string{"像素", "战士", "持剑", "精灵表"}, + Tags: []string{"像素", "横版动作", "大剑战士", "精灵表", "4x4网格"}, AssetType: "sprite", - Prompt: "生成一个像素风持剑战士的4方向行走精灵表", - UserNote: "需要4方向(上下左右),每方向4帧行走动画", + Prompt: "生成一个2D横版动作游戏角色的连续攻击连招精灵表,侧视角,4行4列网格排列", + UserNote: "角色为持大剑的战士,连招包含4段攻击:横斩→上挑→跳劈→终结重击。每段攻击4帧关键帧,共16帧,按4行×4列网格排列。帧间留8-16px纯白间隙(#FFFFFF)。要求动作流畅有力量感,大剑挥舞轨迹清晰", } out, err := service.RunPromptAgent(ctx, agentIn) if err != nil { @@ -42,7 +40,7 @@ func main() { // 2. 调用文生图 API fmt.Println("=== Step 2: Generate sprite sheet via image API ===") params := service.AssetParams{ - Resolution: 1024, + Resolution: 1536, Format: "spritesheet", } images, err := service.GenerateImages(ctx, out.Prompt, params) @@ -63,17 +61,13 @@ func main() { } fmt.Printf("Saved sprite sheet → %s (%d bytes)\n", sheetPath, len(images[0].Data)) - // 3. splitsprite 拆分精灵表 - fmt.Println("\n=== Step 3: Split sprite sheet ===") + // 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() - opts.GridRows = 4 - opts.GridCols = 4 - opts.GridPadding = 2 - opts.CenterAlign = true + opts := splitsprite.DefaultOptions() // WhiteBg=true, MinFillRatio=0.4, CenterAlign=true frames, err := splitsprite.Process(sheetImg, opts) if err != nil { fatalf("split failed: %v", err) @@ -92,7 +86,7 @@ func main() { // 4. 生成 GIF 预览 fmt.Println("\n=== Step 4: Generate GIF preview ===") gifPath := "test_output/preview.gif" - if err := genGIF(gifPath, frames, 12); err != nil { + 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)) @@ -104,63 +98,6 @@ func main() { fmt.Println(" test_output/preview.gif — animated GIF preview") } -func genGIF(path string, frames []image.Image, delay int) error { - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - - pal := buildPalette(frames) - anim := &gif.GIF{} - for _, frame := range frames { - b := frame.Bounds() - paletted := image.NewPaletted(b, pal) - draw.Draw(paletted, b, frame, b.Min, draw.Src) - anim.Image = append(anim.Image, paletted) - anim.Delay = append(anim.Delay, delay) - } - anim.LoopCount = 0 // loop forever - return gif.EncodeAll(f, anim) -} - -func buildPalette(frames []image.Image) color.Palette { - hist := make(map[color.RGBA]int) - sampleStep := max(1, len(frames)/8) - for i := 0; i < len(frames); i += sampleStep { - b := frames[i].Bounds() - step := max(1, (b.Dx()*b.Dy())/4096) - n := 0 - for y := b.Min.Y; y < b.Max.Y; y++ { - for x := b.Min.X; x < b.Max.X; x++ { - if n%step != 0 { - n++ - continue - } - n++ - r, g, bl, a := frames[i].At(x, y).RGBA() - if a > 0 { - c := color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(bl >> 8), A: uint8(a >> 8)} - hist[c]++ - } - } - } - } - pal := make(color.Palette, 0, 256) - for c := range hist { - pal = append(pal, c) - if len(pal) >= 240 { - break - } - } - pal = append(pal, - color.RGBA{0, 0, 0, 0}, - color.RGBA{0, 0, 0, 255}, - color.RGBA{255, 255, 255, 255}, - ) - return pal -} - func decodePNG(data []byte) (image.Image, error) { img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { From d5e2d7fa53b0a79f96350f1163d83273fa0fc3dc Mon Sep 17 00:00:00 2001 From: Gmaker689 <1711322114@qq.com> Date: Mon, 25 May 2026 18:01:51 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20FormatAdapter=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=20splitsprite+gifmaker=20=E7=AE=A1=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FormatAdapter: 精灵表模式(单图)自动调用 splitsprite.Process 拆分帧 - 拆分后每帧编码为独立 PNG Asset,Metadata 包含 GIFPreview 字节 - 多图/非精灵表模式保持原样透传 - AssetParams 新增 GridRows/GridCols 覆盖投影检测 - AssetMetadata 新增 GIFPreview 字段 --- backend/internal/service/nodes.go | 98 +++++++++++++++++++++++++++---- backend/internal/service/types.go | 6 +- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/backend/internal/service/nodes.go b/backend/internal/service/nodes.go index c64156d..56c35b3 100755 --- a/backend/internal/service/nodes.go +++ b/backend/internal/service/nodes.go @@ -1,10 +1,16 @@ package service import ( + "bytes" "context" "fmt" + "image/png" "strings" + "gen2d/internal/logger" + "gen2d/pkg/gifmaker" + "gen2d/pkg/splitsprite" + "github.com/cloudwego/eino/compose" ) @@ -127,7 +133,7 @@ var qualitySupervisorNode = compose.InvokableLambda(func(ctx context.Context, im return input, nil }) -// formatAdapterNode 节点:从 state 读取图片,格式转换,组装输出。 +// 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 { @@ -135,6 +141,18 @@ var formatAdapterNode = compose.InvokableLambda(func(ctx context.Context, input 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{ @@ -144,23 +162,79 @@ var formatAdapterNode = compose.InvokableLambda(func(ctx context.Context, input } } - resolution := input.Params.Resolution - if resolution <= 0 { - resolution = 64 + 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) } - metadata := AssetMetadata{ - FrameWidth: resolution, - FrameHeight: resolution, - FrameCount: len(images), - Directions: input.Params.Frames.Directions, + 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{ - Assets: assets, - Metadata: metadata, + Assets: assets, + Metadata: AssetMetadata{ + FrameWidth: fw, + FrameHeight: fh, + FrameCount: len(frames), + Directions: params.Frames.Directions, + GIFPreview: gifBuf.Bytes(), + }, }, nil -}) +} // buildStyleDescription 将风格键值对转为自然语言描述,供 PromptAgent 注入。 func buildStyleDescription(projectStyle, taskStyle map[string]string) string { diff --git a/backend/internal/service/types.go b/backend/internal/service/types.go index 9db9f27..ee97c3c 100755 --- a/backend/internal/service/types.go +++ b/backend/internal/service/types.go @@ -36,11 +36,14 @@ type AssetParams struct { Resolution int Frames FrameParams Format string // "spritesheet" / "individual" + // GridRows / GridCols override projection-based split for sprite sheets. + GridRows int + GridCols int } // FrameParams 帧参数 type FrameParams struct { - Directions int + Directions int FramesPerDirection int } @@ -65,4 +68,5 @@ type AssetMetadata struct { FrameHeight int FrameCount int Directions int + GIFPreview []byte `json:"-"` // animated GIF preview (not serialized) } From 96ac56cb4e04e9b10b2c856ee0693be2f38bbc15 Mon Sep 17 00:00:00 2001 From: Gmaker689 <1711322114@qq.com> Date: Mon, 25 May 2026 19:10:46 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=AE=A1=E7=BA=BF?= =?UTF-8?q?context=E5=8F=96=E6=B6=88=E3=80=81=E6=B7=BB=E5=8A=A0FIFO?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E9=98=9F=E5=88=97=E3=80=81=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=8F=90=E7=A4=BA=E8=AF=8D=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个修复: 1. runPipelineBg 改用 context.Background(),避免 HTTP 响应返回后 Gin 取消 request context 导致后台管线静默失败 2. 新增 TaskQueue FIFO 串行队列,任务提交后进入 pending 状态排队, 按提交顺序逐个执行,前端轮询显示排队中 3. promptOptimizerNode 移除 RunPromptAgent 调用,提示词优化仅由 前端在提交前通过 /api/v1/prompt/optimize 执行一次,管线内只做 风格合并和技术参数追加 --- backend/cmd/main.go | 5 ++ backend/internal/handler/generate.go | 38 +++++++--- backend/internal/service/nodes.go | 27 ++----- backend/internal/service/queue.go | 104 +++++++++++++++++++++++++++ frontend/src/stores/generation.ts | 2 +- 5 files changed, 145 insertions(+), 31 deletions(-) create mode 100644 backend/internal/service/queue.go diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 967a175..5d06703 100755 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -44,6 +44,11 @@ func main() { // 初始化项目服务 service.InitProjectService(db.GetDB()) + // 初始化生成任务队列(FIFO 串行执行) + generateQueue := service.NewTaskQueue() + handler.SetGenerateQueue(generateQueue) + defer generateQueue.Stop() + r := gin.New() r.Use(mildware.Logger()) r.Use(mildware.Recovery()) diff --git a/backend/internal/handler/generate.go b/backend/internal/handler/generate.go index b5fb767..e9f928f 100755 --- a/backend/internal/handler/generate.go +++ b/backend/internal/handler/generate.go @@ -16,6 +16,14 @@ import ( "github.com/gin-gonic/gin" ) +// generateQueue 全局生成任务队列,由 main 通过 SetGenerateQueue 注入。 +var generateQueue *service.TaskQueue + +// SetGenerateQueue 设置生成任务队列。 +func SetGenerateQueue(q *service.TaskQueue) { + generateQueue = q +} + // GenerateRequest 素材生成请求。 type GenerateRequest struct { ProjectID string `json:"projectId"` @@ -43,7 +51,7 @@ type AssetsResponse struct { } // Generate 素材生成接口(异步)。 -// 立即返回 taskId,后台执行管线,前端通过 GET /tasks/:taskId 轮询进度。 +// 立即返回 taskId,任务进入 FIFO 队列串行执行,前端通过 GET /tasks/:taskId 轮询进度。 func Generate(c *gin.Context) { var req GenerateRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -57,18 +65,29 @@ func Generate(c *gin.Context) { } taskID := fmt.Sprintf("task-%d", time.Now().UnixMilli()) - // 保存任务到数据库 + // 保存任务到数据库,初始状态为 pending if err := saveTaskToDB(c.Request.Context(), projectID, taskID, req); err != nil { logger.FromCtx(c.Request.Context()).Error("failed to save task", "error", err) c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "创建任务失败")) return } - // 返回 taskId - c.JSON(http.StatusOK, model.OK(GenerateResponse{TaskID: taskID})) + // 加入 FIFO 队列 + queuePos := 1 + if generateQueue != nil { + queuePos = generateQueue.Enqueue(&service.TaskJob{ + Ctx: context.Background(), + ProjectID: projectID, + TaskID: taskID, + Execute: func(ctx context.Context) error { + runPipelineBg(ctx, projectID, taskID, req) + return nil + }, + }) + } - // 后台执行管线 - go runPipelineBg(c.Request.Context(), projectID, taskID, req) + c.JSON(http.StatusOK, model.OK(GenerateResponse{TaskID: taskID})) + _ = queuePos } // runPipelineBg 后台执行生成管线,更新任务状态。 @@ -82,6 +101,7 @@ func runPipelineBg(ctx context.Context, projectID, taskID string, req GenerateRe updateTaskInDB(ctx, taskID, "running", stage, "", progress) }) + // 队列调度后才标记为 running,初始写入时是 pending updateTaskInDB(ctx, taskID, "running", "prompt_builder", "", 5) in := service.PipelineInput{ @@ -240,9 +260,9 @@ func saveTaskToDB(ctx context.Context, projectID, taskID string, req GenerateReq ProjectID: uint(projectIDUint), Prompt: req.Prompt, AssetType: req.AssetType, - Status: "running", - Progress: 5, - Stage: "prompt_builder", + Status: "pending", + Progress: 0, + Stage: "", RetryCount: 0, CreatedAt: time.Now(), UpdatedAt: time.Now(), diff --git a/backend/internal/service/nodes.go b/backend/internal/service/nodes.go index 56c35b3..ce7c847 100755 --- a/backend/internal/service/nodes.go +++ b/backend/internal/service/nodes.go @@ -14,10 +14,10 @@ import ( "github.com/cloudwego/eino/compose" ) -// promptOptimizerNode 节点:调用 PromptAgent 生成规范提示词,合并风格与重试信息。 -// 输入 PipelineInput,输出最终提示词字符串(直接供 AssetGenerator 消费)。 +// promptOptimizerNode 节点:合并风格描述、重试信息与技术参数,输出最终提示词。 +// 提示词优化已由前端在提交前完成,管线内不再重复调用 PromptAgent。 var promptOptimizerNode = compose.InvokableLambda(func(ctx context.Context, in PipelineInput) (string, error) { - // 合并风格描述,注入原始 Prompt 中 + // 合并风格描述 styleDesc := buildStyleDescription(in.ProjectStyle, in.TaskStyle) if styleDesc != "" { if in.Prompt != "" { @@ -36,26 +36,11 @@ var promptOptimizerNode = compose.InvokableLambda(func(ctx context.Context, in P } } - if len(in.Tags) == 0 && in.Prompt == "" { - return "", fmt.Errorf("pipeline: Prompt and Tags are both empty") + if in.Prompt == "" { + return "", fmt.Errorf("pipeline: prompt is empty") } - // 有标签时调用 PromptAgent 优化提示词 - if len(in.Tags) > 0 { - agentIn := PromptAgentInput{ - Tags: in.Tags, - AssetType: in.AssetType, - Prompt: in.Prompt, - UserNote: in.UserNote, - } - output, err := RunPromptAgent(ctx, agentIn) - if err != nil { - return "", fmt.Errorf("prompt agent: %w", err) - } - return output.Prompt, nil - } - - // 无标签时直接使用原始 Prompt,补上技术参数段 + // 追加技术参数段,不再调用 PromptAgent return appendTechNotes(in.Prompt, in.AssetType, in.Params), nil }) diff --git a/backend/internal/service/queue.go b/backend/internal/service/queue.go new file mode 100644 index 0000000..ea89ce0 --- /dev/null +++ b/backend/internal/service/queue.go @@ -0,0 +1,104 @@ +package service + +import ( + "context" + "sync" + + "gen2d/internal/logger" +) + +// TaskJob 队列中的任务。 +type TaskJob struct { + Ctx context.Context + ProjectID string + TaskID string + Execute func(ctx context.Context) error +} + +// TaskQueue 串行 FIFO 任务队列。 +type TaskQueue struct { + mu sync.Mutex + jobs []*TaskJob + ready chan struct{} + stop chan struct{} + stopped bool +} + +// NewTaskQueue 创建任务队列并启动调度协程。 +func NewTaskQueue() *TaskQueue { + q := &TaskQueue{ + jobs: make([]*TaskJob, 0), + ready: make(chan struct{}, 1), + stop: make(chan struct{}), + } + go q.run() + return q +} + +// Enqueue 将任务加入队尾,返回队列中的位置(1-based)。 +func (q *TaskQueue) Enqueue(job *TaskJob) int { + q.mu.Lock() + defer q.mu.Unlock() + q.jobs = append(q.jobs, job) + pos := len(q.jobs) + select { + case q.ready <- struct{}{}: + default: + } + return pos +} + +// QueueLen 返回当前队列长度。 +func (q *TaskQueue) QueueLen() int { + q.mu.Lock() + defer q.mu.Unlock() + return len(q.jobs) +} + +// Stop 优雅关闭队列。 +func (q *TaskQueue) Stop() { + q.mu.Lock() + defer q.mu.Unlock() + if !q.stopped { + q.stopped = true + close(q.stop) + } +} + +func (q *TaskQueue) run() { + for { + select { + case <-q.stop: + return + case <-q.ready: + q.processNext() + } + } +} + +func (q *TaskQueue) processNext() { + q.mu.Lock() + if len(q.jobs) == 0 { + q.mu.Unlock() + return + } + job := q.jobs[0] + q.jobs = q.jobs[1:] + // 如果队列还有任务,重新发信号 + if len(q.jobs) > 0 { + select { + case q.ready <- struct{}{}: + default: + } + } + q.mu.Unlock() + + l := logger.With("task_id", job.TaskID, "project_id", job.ProjectID) + l.Info("task queue executing job", "queue_remaining", len(q.jobs)) + + if err := job.Execute(job.Ctx); err != nil { + l.Error("task job failed", "error", err) + } else { + l.Info("task job completed") + } +} diff --git a/frontend/src/stores/generation.ts b/frontend/src/stores/generation.ts index 6686a2f..9f740ea 100755 --- a/frontend/src/stores/generation.ts +++ b/frontend/src/stores/generation.ts @@ -120,7 +120,7 @@ export const useGenerationStore = create((set) => ({ function stageLabel(stage?: string): string { switch (stage) { - case 'prompt_builder': return '优化提示词...' + case 'prompt_builder': return '构建提示词...' case 'asset_generator': return '生成素材中...' case 'quality_supervisor': return '质检中...' case 'format_adapter': return '格式转换中...'