From ab2b72e7460369d71b0bbd1130d69c29c6a0e9d3 Mon Sep 17 00:00:00 2001 From: Gmaker689 <1711322114@qq.com> Date: Mon, 25 May 2026 19:31:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=B7=A5=E7=A8=8B=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E4=B8=8E=E7=B4=A0=E6=9D=90=E9=A3=8E=E6=A0=BC=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E5=88=86=E7=A6=BB=EF=BC=8C=E6=94=AF=E6=8C=81=E5=8F=8C=E6=A0=8F?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工程层面:新增 PROJECT_TYPES(2D平台跳跃/角色扮演等8种),替代原先与素材生成重复的风格标签栏 - 素材生成层面:StyleSelector 作为素材风格主配置项,新增 CustomTagsEditor 支持任务级自定义标签 - extractTags 合并工程类型、风格分类、工程自定义标签、任务自定义标签 - StyleSelector 支持 categories 参数复用 - 后端:spritesheet 拆分失败回退为单帧、GIF 上传为独立素材、AssetResponse 增加 metadata 字段 --- backend/internal/handler/generate.go | 30 ++++++++++------- backend/internal/model/asset.go | 7 ++-- backend/internal/service/nodes.go | 33 ++++++++++++++----- backend/internal/service/types.go | 10 +++--- frontend/src/api/prompt.ts | 26 +++++++++++---- frontend/src/api/types.ts | 1 + .../src/components/CreateProjectModal.tsx | 14 ++++++-- frontend/src/components/GenerateForm.tsx | 7 ++-- frontend/src/components/ProjectCard.tsx | 15 ++++----- .../src/components/ProjectConfigPanel.tsx | 14 ++++++-- frontend/src/components/PromptEditor.tsx | 25 ++++++++++---- frontend/src/components/StyleSelector.tsx | 6 +++- frontend/src/stores/task.ts | 3 ++ frontend/src/utils/style.ts | 15 +++++++++ 14 files changed, 149 insertions(+), 57 deletions(-) diff --git a/backend/internal/handler/generate.go b/backend/internal/handler/generate.go index 936a972..f58705b 100755 --- a/backend/internal/handler/generate.go +++ b/backend/internal/handler/generate.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "time" "gen2d/internal/db" @@ -46,8 +47,8 @@ type GenerateResponse struct { // AssetsResponse 素材列表响应。 type AssetsResponse struct { - Assets []model.AssetResponse `json:"assets"` - Metadata service.AssetMetadata `json:"metadata"` + Assets []model.AssetResponse `json:"assets"` + Metadata service.AssetMetadata `json:"metadata"` } // Generate 素材生成接口(异步)。 @@ -135,6 +136,7 @@ func runPipelineBg(ctx context.Context, projectID, taskID string, req GenerateRe updateTaskInDB(ctx, taskID, "saving", "format_adapter", "", 90) // 上传素材并保存到数据库 + var lastCDNURL string for i, a := range output.Assets { key := fmt.Sprintf("generation/%s/%s/%d.%s", projectID, taskID, i, a.Format) cdnURL, err := storageSvc.Upload(ctx, key, a.Data) @@ -143,15 +145,15 @@ func runPipelineBg(ctx context.Context, projectID, taskID string, req GenerateRe updateTaskInDB(ctx, taskID, "failed", "", "上传素材失败: "+err.Error(), 0) return } + lastCDNURL = cdnURL - // 序列化单个素材的元数据 - var metadata map[string]interface{} - if i < len(output.Assets) { - metadata = map[string]interface{}{ - "index": i, - } + var assetMeta map[string]interface{} + if a.Format == "gif" { + assetMeta = map[string]interface{}{"index": i, "type": "preview"} + } else { + assetMeta = map[string]interface{}{"index": i} } - metadataJSON, _ := json.Marshal(metadata) + metadataJSON, _ := json.Marshal(assetMeta) asset := &model.Asset{ TaskID: getTaskDBID(ctx, taskID), @@ -166,7 +168,11 @@ func runPipelineBg(ctx context.Context, projectID, taskID string, req GenerateRe } } - // 更新为完成状态 + // GIF URL 替换为实际上传后的 CDN 地址 + if output.Metadata.GIFURL != "" && lastCDNURL != "" { + output.Metadata.GIFURL = lastCDNURL + } + var fullMetadata string if metadataJSON, err := json.Marshal(output.Metadata); err == nil { fullMetadata = string(metadataJSON) @@ -274,8 +280,8 @@ func saveTaskToDB(ctx context.Context, projectID, taskID string, req GenerateReq // updateTaskInDB 更新数据库中的任务状态。 func updateTaskInDB(ctx context.Context, taskID, status, stage, error string, progress int) { updates := map[string]interface{}{ - "status": status, - "progress": progress, + "status": status, + "progress": progress, "updated_at": time.Now(), } if stage != "" { diff --git a/backend/internal/model/asset.go b/backend/internal/model/asset.go index c9c12b1..62d1b40 100644 --- a/backend/internal/model/asset.go +++ b/backend/internal/model/asset.go @@ -16,7 +16,8 @@ type Asset struct { // AssetResponse 素材响应。 type AssetResponse struct { - Key string `json:"key"` - URL string `json:"url"` - Format string `json:"format"` + Key string `json:"key"` + URL string `json:"url"` + Format string `json:"format"` + Metadata string `json:"metadata"` // 素材元数据(JSON: {"index":0,"type":"frame"|"preview"|"spritesheet"}) } diff --git a/backend/internal/service/nodes.go b/backend/internal/service/nodes.go index ce7c847..9091cce 100755 --- a/backend/internal/service/nodes.go +++ b/backend/internal/service/nodes.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "image" "image/png" "strings" @@ -159,6 +160,7 @@ var formatAdapterNode = compose.InvokableLambda(func(ctx context.Context, input }) // 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)) @@ -175,13 +177,19 @@ func processSpriteSheet(ctx context.Context, img GeneratedImage, params AssetPar 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.Warn("format_adapter split sprite sheet failed, falling back to single frame", "error", err) + // 回退:整张图作为单帧 + frames = []image.Image{src} } l.Info("format_adapter split sprite sheet", "frame_count", len(frames)) - // 帧 → Asset - assets := make([]Asset, 0, len(frames)) + // 帧 → Asset(先保留原始精灵表,再追加拆分后的帧) + assets := make([]Asset, 0, len(frames)+2) + assets = append(assets, Asset{ + Data: img.Data, + Format: img.Format, + URL: "output/spritesheet.png", + }) for i, f := range frames { var buf bytes.Buffer if err := png.Encode(&buf, f); err != nil { @@ -195,12 +203,19 @@ func processSpriteSheet(ctx context.Context, img GeneratedImage, params AssetPar }) } - // GIF 预览 + // GIF 预览 → Asset(排在帧之后) + var gifURL string var gifBuf bytes.Buffer if err := gifmaker.Encode(&gifBuf, frames, nil); err != nil { l.Warn("format_adapter generate GIF preview failed", "error", err) } else { l.Info("format_adapter generated GIF preview", "size_bytes", gifBuf.Len()) + gifURL = "output/preview.gif" + assets = append(assets, Asset{ + Data: gifBuf.Bytes(), + Format: "gif", + URL: gifURL, + }) } fw, fh := 0, 0 @@ -216,7 +231,7 @@ func processSpriteSheet(ctx context.Context, img GeneratedImage, params AssetPar FrameHeight: fh, FrameCount: len(frames), Directions: params.Frames.Directions, - GIFPreview: gifBuf.Bytes(), + GIFURL: gifURL, }, }, nil } @@ -234,7 +249,7 @@ func buildStyleDescription(projectStyle, taskStyle map[string]string) string { return "风格约束:" + strings.Join(parts, ";") } -// appendTechNotes 在无标签(不走 PromptAgent)时补上技术参数段。 +// appendTechNotes 在提示词末尾追加技术参数段。 func appendTechNotes(prompt, assetType string, params AssetParams) string { var parts []string if prompt != "" { @@ -250,7 +265,9 @@ func appendTechNotes(prompt, assetType string, params AssetParams) string { if params.Frames.FramesPerDirection > 0 { parts = append(parts, fmt.Sprintf("每方向帧数: %d", params.Frames.FramesPerDirection)) } - if params.Format != "" { + if params.Format == "spritesheet" { + parts = append(parts, "输出格式: spritesheet(将所有帧排列在一张图上,帧之间用8-16像素纯白间隙分隔,等间距网格布局)") + } else if params.Format != "" { parts = append(parts, fmt.Sprintf("输出格式: %s", params.Format)) } return strings.Join(parts, ";") diff --git a/backend/internal/service/types.go b/backend/internal/service/types.go index ee97c3c..14a7df9 100755 --- a/backend/internal/service/types.go +++ b/backend/internal/service/types.go @@ -64,9 +64,9 @@ type Asset struct { // AssetMetadata 素材元数据 type AssetMetadata struct { - FrameWidth int - FrameHeight int - FrameCount int - Directions int - GIFPreview []byte `json:"-"` // animated GIF preview (not serialized) + FrameWidth int `json:"frameWidth"` + FrameHeight int `json:"frameHeight"` + FrameCount int `json:"frameCount"` + Directions int `json:"directions"` + GIFURL string `json:"gifUrl,omitempty"` // animated GIF preview URL } diff --git a/frontend/src/api/prompt.ts b/frontend/src/api/prompt.ts index 89464fe..f023b57 100755 --- a/frontend/src/api/prompt.ts +++ b/frontend/src/api/prompt.ts @@ -1,5 +1,5 @@ import { post } from './client' -import { STYLE_CATEGORIES, getCustomTags } from '../utils/style' +import { STYLE_CATEGORIES, PROJECT_TYPES, PROJECT_TYPE_KEY, getCustomTags } from '../utils/style' interface OptimizePromptParams { tags: string[] @@ -17,14 +17,26 @@ interface OptimizePromptResponse { * 从风格键值对中提取标签的中文名称作为 tags,包含自定义标签 */ export function extractTags(style: Record): string[] { - const presetTags = STYLE_CATEGORIES.flatMap(cat => { + const tags: string[] = [] + + // 工程类型标签 + const projectType = style[PROJECT_TYPE_KEY] + if (projectType) { + const pt = PROJECT_TYPES.find(p => p.value === projectType) + if (pt) tags.push(pt.label) + } + + // 风格分类标签 + for (const cat of STYLE_CATEGORIES) { const value = style[cat.key] - if (!value) return [] + if (!value) continue const option = cat.options.find(o => o.value === value) - return option ? [option.label] : [] - }) - const customTags = getCustomTags(style) - return [...presetTags, ...customTags] + if (option) tags.push(option.label) + } + + // 自定义标签 + tags.push(...getCustomTags(style)) + return tags } /** diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 37cf694..03ae35f 100755 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -127,6 +127,7 @@ export interface AssetsResponse { frameHeight: number frameCount: number directions: number + gifUrl?: string } } diff --git a/frontend/src/components/CreateProjectModal.tsx b/frontend/src/components/CreateProjectModal.tsx index 919860e..3edd8ec 100644 --- a/frontend/src/components/CreateProjectModal.tsx +++ b/frontend/src/components/CreateProjectModal.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { PROJECT_TYPES, PROJECT_TYPE_KEY } from '../utils/style' import StyleSelector from './StyleSelector' import CustomTagsEditor from './CustomTagsEditor' @@ -91,9 +92,18 @@ export default function CreateProjectModal({
- 风格配置 + 工程类型 - + ({ value: p.value, label: p.label })), + }]} + />
diff --git a/frontend/src/components/GenerateForm.tsx b/frontend/src/components/GenerateForm.tsx index e625e43..02c29ce 100755 --- a/frontend/src/components/GenerateForm.tsx +++ b/frontend/src/components/GenerateForm.tsx @@ -3,6 +3,7 @@ import { useProjectStore } from '../stores/project' import { useTaskStore } from '../stores/task' import { mergeStyles } from '../utils/style' import StyleSelector from './StyleSelector' +import CustomTagsEditor from './CustomTagsEditor' import PromptEditor from './PromptEditor' const ASSET_TYPE_ICONS: Record = { @@ -53,6 +54,7 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps setPrompt, setAssetType, toggleTaskStyle, + setTaskStyle, setParams, setEnableAI, setOptimizedPrompt, @@ -115,12 +117,13 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps - {/* 任务风格覆盖 */} + {/* 素材风格配置 */}

- 风格覆盖(可选) + 素材风格

+
{/* 提示词(含 AI 优化) */} diff --git a/frontend/src/components/ProjectCard.tsx b/frontend/src/components/ProjectCard.tsx index 127dc5d..4b70df6 100644 --- a/frontend/src/components/ProjectCard.tsx +++ b/frontend/src/components/ProjectCard.tsx @@ -1,6 +1,6 @@ import { useNavigate } from 'react-router-dom' import type { Project } from '../api/types' -import { STYLE_CATEGORIES } from '../utils/style' +import { PROJECT_TYPES, PROJECT_TYPE_KEY, getCustomTags } from '../utils/style' interface ProjectCardProps { project: Project @@ -23,12 +23,11 @@ function formatRelativeTime(dateStr: string): string { export default function ProjectCard({ project, onDelete }: ProjectCardProps) { const navigate = useNavigate() - const presetTags = STYLE_CATEGORIES.flatMap(cat => { - const value = project.style.kvPairs[cat.key] - if (!value) return [] - const option = cat.options.find(o => o.value === value) - return option ? [option.label] : [] - }).slice(0, 4) + const kvPairs = project.style.kvPairs + const projectTypeValue = kvPairs[PROJECT_TYPE_KEY] + const projectTypeLabel = PROJECT_TYPES.find(p => p.value === projectTypeValue)?.label + const customTags = getCustomTags(kvPairs).slice(0, 4) + const tags = projectTypeLabel ? [projectTypeLabel, ...customTags].slice(0, 4) : customTags return (
{project.name}
- {presetTags.map(tag => ( + {tags.map(tag => (
- 工程风格 + 工程类型
- + ({ value: p.value, label: p.label })), + }]} + />
diff --git a/frontend/src/components/PromptEditor.tsx b/frontend/src/components/PromptEditor.tsx index 06ee98e..fbd43fc 100755 --- a/frontend/src/components/PromptEditor.tsx +++ b/frontend/src/components/PromptEditor.tsx @@ -1,4 +1,4 @@ -import { STYLE_CATEGORIES } from '../utils/style' +import { STYLE_CATEGORIES, PROJECT_TYPES, PROJECT_TYPE_KEY, getCustomTags } from '../utils/style' import styles from './GenerateForm.module.css' interface PromptEditorProps { @@ -17,14 +17,25 @@ interface PromptEditorProps { } function styleToDescription(kvPairs: Record): string { - return STYLE_CATEGORIES.map(cat => { + const parts: string[] = [] + + const projectType = kvPairs[PROJECT_TYPE_KEY] + if (projectType) { + const pt = PROJECT_TYPES.find(p => p.value === projectType) + if (pt) parts.push(`工程类型: ${pt.label}`) + } + + for (const cat of STYLE_CATEGORIES) { const value = kvPairs[cat.key] - if (!value) return null + if (!value) continue const option = cat.options.find(o => o.value === value) - return option ? `${cat.label}: ${option.label}` : null - }) - .filter(Boolean) - .join(', ') + if (option) parts.push(`${cat.label}: ${option.label}`) + } + + const customTags = getCustomTags(kvPairs) + if (customTags.length > 0) parts.push(`标签: ${customTags.join(', ')}`) + + return parts.join(', ') } export default function PromptEditor({ diff --git a/frontend/src/components/StyleSelector.tsx b/frontend/src/components/StyleSelector.tsx index 9f6ef47..06a7578 100755 --- a/frontend/src/components/StyleSelector.tsx +++ b/frontend/src/components/StyleSelector.tsx @@ -1,3 +1,4 @@ +import type { StyleCategory } from '../utils/style' import { STYLE_CATEGORIES } from '../utils/style' import styles from './StyleSelector.module.css' @@ -5,16 +6,19 @@ interface StyleSelectorProps { value: Record onChange: (key: string, value: string) => void compact?: boolean + categories?: StyleCategory[] } export default function StyleSelector({ value, onChange, compact, + categories, }: StyleSelectorProps) { + const cats = categories ?? STYLE_CATEGORIES return (
- {STYLE_CATEGORIES.map(cat => ( + {cats.map(cat => (
{cat.label}
diff --git a/frontend/src/stores/task.ts b/frontend/src/stores/task.ts index 483c271..a156691 100755 --- a/frontend/src/stores/task.ts +++ b/frontend/src/stores/task.ts @@ -20,6 +20,7 @@ interface TaskState { setPrompt: (text: string) => void setAssetType: (type: AssetType) => void toggleTaskStyle: (key: string, value: string) => void + setTaskStyle: (style: Record) => void setParams: (params: Partial) => void setEnableAI: (enable: boolean) => void setOptimizedPrompt: (text: string | null) => void @@ -56,6 +57,8 @@ export const useTaskStore = create((set, get) => ({ return { taskStyle: next, optimizedPrompt: null } }), + setTaskStyle: (style) => set({ taskStyle: style, optimizedPrompt: null }), + setParams: (params) => set(state => ({ params: { ...state.params, ...params } })), diff --git a/frontend/src/utils/style.ts b/frontend/src/utils/style.ts index d7cf065..55d979b 100755 --- a/frontend/src/utils/style.ts +++ b/frontend/src/utils/style.ts @@ -44,6 +44,21 @@ export interface StyleCategory { options: { value: string; label: string }[] } +/** 工程类型键名 */ +export const PROJECT_TYPE_KEY = 'projectType' + +/** 工程类型选项(决定项目整体类型,区别于素材生成的风格标签) */ +export const PROJECT_TYPES: { value: string; label: string }[] = [ + { value: '2d-platformer', label: '2D 平台跳跃' }, + { value: 'rpg', label: '角色扮演' }, + { value: 'top-down', label: '俯视视角' }, + { value: 'visual-novel', label: '视觉小说' }, + { value: 'tower-defense', label: '塔防' }, + { value: 'puzzle', label: '益智解谜' }, + { value: 'shooter', label: '射击' }, + { value: 'roguelike', label: '肉鸽' }, +] + export const STYLE_CATEGORIES: StyleCategory[] = [ { key: 'artStyle',