fix: 工程类型与素材风格标签分离,支持双栏自定义标签

- 工程层面:新增 PROJECT_TYPES(2D平台跳跃/角色扮演等8种),替代原先与素材生成重复的风格标签栏
- 素材生成层面:StyleSelector 作为素材风格主配置项,新增 CustomTagsEditor 支持任务级自定义标签
- extractTags 合并工程类型、风格分类、工程自定义标签、任务自定义标签
- StyleSelector 支持 categories 参数复用
- 后端:spritesheet 拆分失败回退为单帧、GIF 上传为独立素材、AssetResponse 增加 metadata 字段
This commit is contained in:
2026-05-25 19:31:36 +08:00
parent c48ba6e61d
commit ab2b72e746
14 changed files with 149 additions and 57 deletions
+14 -8
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"gen2d/internal/db"
@@ -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)
+1
View File
@@ -19,4 +19,5 @@ type AssetResponse struct {
Key string `json:"key"`
URL string `json:"url"`
Format string `json:"format"`
Metadata string `json:"metadata"` // 素材元数据(JSON: {"index":0,"type":"frame"|"preview"|"spritesheet"})
}
+25 -8
View File
@@ -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, ";")
+5 -5
View File
@@ -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
}
+19 -7
View File
@@ -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, string>): 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
}
/**
+1
View File
@@ -127,6 +127,7 @@ export interface AssetsResponse {
frameHeight: number
frameCount: number
directions: number
gifUrl?: string
}
}
+12 -2
View File
@@ -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({
<div style={{ marginBottom: 16 }}>
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-secondary)' }}>
风格配置
工程类型
</span>
<StyleSelector value={style} onChange={handleStyleChange} compact />
<StyleSelector
value={style}
onChange={handleStyleChange}
compact
categories={[{
key: PROJECT_TYPE_KEY,
label: '工程类型',
options: PROJECT_TYPES.map(p => ({ value: p.value, label: p.label })),
}]}
/>
<CustomTagsEditor style={style} onChange={setStyle} />
</div>
+5 -2
View File
@@ -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<AssetType, JSX.Element> = {
@@ -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
</div>
</div>
{/* 任务风格覆盖 */}
{/* 素材风格配置 */}
<div>
<h3 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}>
风格覆盖(可选)
素材风格
</h3>
<StyleSelector value={taskStyle} onChange={toggleTaskStyle} compact />
<CustomTagsEditor style={taskStyle} onChange={setTaskStyle} />
</div>
{/* 提示词(含 AI 优化) */}
+7 -8
View File
@@ -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 (
<div
@@ -59,7 +58,7 @@ export default function ProjectCard({ project, onDelete }: ProjectCardProps) {
)}
<h3 style={{ fontSize: 16, marginBottom: 12 }}>{project.name}</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{presetTags.map(tag => (
{tags.map(tag => (
<span
key={tag}
style={{
+12 -2
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { useProjectStore } from '../stores/project'
import { useToastStore } from '../stores/toast'
import { PROJECT_TYPES, PROJECT_TYPE_KEY } from '../utils/style'
import StyleSelector from './StyleSelector'
import CustomTagsEditor from './CustomTagsEditor'
import Skeleton from './Skeleton'
@@ -73,9 +74,18 @@ export default function ProjectConfigPanel() {
<div style={{ flex: 1, overflow: 'auto', padding: '12px 16px' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-secondary)' }}>
工程风格
工程类型
</div>
<StyleSelector value={draftStyle} onChange={updateDraft} compact />
<StyleSelector
value={draftStyle}
onChange={updateDraft}
compact
categories={[{
key: PROJECT_TYPE_KEY,
label: '工程类型',
options: PROJECT_TYPES.map(p => ({ value: p.value, label: p.label })),
}]}
/>
<CustomTagsEditor style={draftStyle} onChange={setDraftStyle} />
</div>
+18 -7
View File
@@ -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, string>): 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({
+5 -1
View File
@@ -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<string, string>
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 (
<div className={`${styles.container} ${compact ? styles.compact : ''}`}>
{STYLE_CATEGORIES.map(cat => (
{cats.map(cat => (
<div key={cat.key} className={styles.category}>
<span className={styles.label}>{cat.label}</span>
<div className={styles.options}>
+3
View File
@@ -20,6 +20,7 @@ interface TaskState {
setPrompt: (text: string) => void
setAssetType: (type: AssetType) => void
toggleTaskStyle: (key: string, value: string) => void
setTaskStyle: (style: Record<string, string>) => void
setParams: (params: Partial<TaskParams>) => void
setEnableAI: (enable: boolean) => void
setOptimizedPrompt: (text: string | null) => void
@@ -56,6 +57,8 @@ export const useTaskStore = create<TaskState>((set, get) => ({
return { taskStyle: next, optimizedPrompt: null }
}),
setTaskStyle: (style) => set({ taskStyle: style, optimizedPrompt: null }),
setParams: (params) =>
set(state => ({ params: { ...state.params, ...params } })),
+15
View File
@@ -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',