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" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"gen2d/internal/db" "gen2d/internal/db"
@@ -135,6 +136,7 @@ func runPipelineBg(ctx context.Context, projectID, taskID string, req GenerateRe
updateTaskInDB(ctx, taskID, "saving", "format_adapter", "", 90) updateTaskInDB(ctx, taskID, "saving", "format_adapter", "", 90)
// 上传素材并保存到数据库 // 上传素材并保存到数据库
var lastCDNURL string
for i, a := range output.Assets { for i, a := range output.Assets {
key := fmt.Sprintf("generation/%s/%s/%d.%s", projectID, taskID, i, a.Format) key := fmt.Sprintf("generation/%s/%s/%d.%s", projectID, taskID, i, a.Format)
cdnURL, err := storageSvc.Upload(ctx, key, a.Data) 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) updateTaskInDB(ctx, taskID, "failed", "", "上传素材失败: "+err.Error(), 0)
return return
} }
lastCDNURL = cdnURL
// 序列化单个素材的元数据 var assetMeta map[string]interface{}
var metadata map[string]interface{} if a.Format == "gif" {
if i < len(output.Assets) { assetMeta = map[string]interface{}{"index": i, "type": "preview"}
metadata = map[string]interface{}{ } else {
"index": i, assetMeta = map[string]interface{}{"index": i}
} }
} metadataJSON, _ := json.Marshal(assetMeta)
metadataJSON, _ := json.Marshal(metadata)
asset := &model.Asset{ asset := &model.Asset{
TaskID: getTaskDBID(ctx, taskID), 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 var fullMetadata string
if metadataJSON, err := json.Marshal(output.Metadata); err == nil { if metadataJSON, err := json.Marshal(output.Metadata); err == nil {
fullMetadata = string(metadataJSON) fullMetadata = string(metadataJSON)
+1
View File
@@ -19,4 +19,5 @@ type AssetResponse struct {
Key string `json:"key"` Key string `json:"key"`
URL string `json:"url"` URL string `json:"url"`
Format string `json:"format"` 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" "bytes"
"context" "context"
"fmt" "fmt"
"image"
"image/png" "image/png"
"strings" "strings"
@@ -159,6 +160,7 @@ var formatAdapterNode = compose.InvokableLambda(func(ctx context.Context, input
}) })
// processSpriteSheet 将单张精灵表拆分为独立帧并生成 GIF 预览。 // processSpriteSheet 将单张精灵表拆分为独立帧并生成 GIF 预览。
// 拆分失败时回退为单帧,不阻塞管线。
func processSpriteSheet(ctx context.Context, img GeneratedImage, params AssetParams, resolution int) (PipelineOutput, error) { func processSpriteSheet(ctx context.Context, img GeneratedImage, params AssetParams, resolution int) (PipelineOutput, error) {
l := logger.FromCtx(ctx) l := logger.FromCtx(ctx)
src, err := png.Decode(bytes.NewReader(img.Data)) 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) frames, err := splitsprite.Process(src, opts)
if err != nil { if err != nil {
l.Error("format_adapter split sprite sheet failed", "error", err) l.Warn("format_adapter split sprite sheet failed, falling back to single frame", "error", err)
return PipelineOutput{}, fmt.Errorf("split sprite sheet: %w", err) // 回退:整张图作为单帧
frames = []image.Image{src}
} }
l.Info("format_adapter split sprite sheet", "frame_count", len(frames)) l.Info("format_adapter split sprite sheet", "frame_count", len(frames))
// 帧 → Asset // 帧 → Asset(先保留原始精灵表,再追加拆分后的帧)
assets := make([]Asset, 0, len(frames)) 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 { for i, f := range frames {
var buf bytes.Buffer var buf bytes.Buffer
if err := png.Encode(&buf, f); err != nil { 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 var gifBuf bytes.Buffer
if err := gifmaker.Encode(&gifBuf, frames, nil); err != nil { if err := gifmaker.Encode(&gifBuf, frames, nil); err != nil {
l.Warn("format_adapter generate GIF preview failed", "error", err) l.Warn("format_adapter generate GIF preview failed", "error", err)
} else { } else {
l.Info("format_adapter generated GIF preview", "size_bytes", gifBuf.Len()) 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 fw, fh := 0, 0
@@ -216,7 +231,7 @@ func processSpriteSheet(ctx context.Context, img GeneratedImage, params AssetPar
FrameHeight: fh, FrameHeight: fh,
FrameCount: len(frames), FrameCount: len(frames),
Directions: params.Frames.Directions, Directions: params.Frames.Directions,
GIFPreview: gifBuf.Bytes(), GIFURL: gifURL,
}, },
}, nil }, nil
} }
@@ -234,7 +249,7 @@ func buildStyleDescription(projectStyle, taskStyle map[string]string) string {
return "风格约束:" + strings.Join(parts, ";") return "风格约束:" + strings.Join(parts, ";")
} }
// appendTechNotes 在无标签(不走 PromptAgent)时补上技术参数段。 // appendTechNotes 在提示词末尾追加技术参数段。
func appendTechNotes(prompt, assetType string, params AssetParams) string { func appendTechNotes(prompt, assetType string, params AssetParams) string {
var parts []string var parts []string
if prompt != "" { if prompt != "" {
@@ -250,7 +265,9 @@ func appendTechNotes(prompt, assetType string, params AssetParams) string {
if params.Frames.FramesPerDirection > 0 { if params.Frames.FramesPerDirection > 0 {
parts = append(parts, fmt.Sprintf("每方向帧数: %d", params.Frames.FramesPerDirection)) 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)) parts = append(parts, fmt.Sprintf("输出格式: %s", params.Format))
} }
return strings.Join(parts, ";") return strings.Join(parts, ";")
+5 -5
View File
@@ -64,9 +64,9 @@ type Asset struct {
// AssetMetadata 素材元数据 // AssetMetadata 素材元数据
type AssetMetadata struct { type AssetMetadata struct {
FrameWidth int FrameWidth int `json:"frameWidth"`
FrameHeight int FrameHeight int `json:"frameHeight"`
FrameCount int FrameCount int `json:"frameCount"`
Directions int Directions int `json:"directions"`
GIFPreview []byte `json:"-"` // animated GIF preview (not serialized) GIFURL string `json:"gifUrl,omitempty"` // animated GIF preview URL
} }
+19 -7
View File
@@ -1,5 +1,5 @@
import { post } from './client' 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 { interface OptimizePromptParams {
tags: string[] tags: string[]
@@ -17,14 +17,26 @@ interface OptimizePromptResponse {
* 从风格键值对中提取标签的中文名称作为 tags,包含自定义标签 * 从风格键值对中提取标签的中文名称作为 tags,包含自定义标签
*/ */
export function extractTags(style: Record<string, string>): string[] { 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] const value = style[cat.key]
if (!value) return [] if (!value) continue
const option = cat.options.find(o => o.value === value) const option = cat.options.find(o => o.value === value)
return option ? [option.label] : [] if (option) tags.push(option.label)
}) }
const customTags = getCustomTags(style)
return [...presetTags, ...customTags] // 自定义标签
tags.push(...getCustomTags(style))
return tags
} }
/** /**
+1
View File
@@ -127,6 +127,7 @@ export interface AssetsResponse {
frameHeight: number frameHeight: number
frameCount: number frameCount: number
directions: number directions: number
gifUrl?: string
} }
} }
+12 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { PROJECT_TYPES, PROJECT_TYPE_KEY } from '../utils/style'
import StyleSelector from './StyleSelector' import StyleSelector from './StyleSelector'
import CustomTagsEditor from './CustomTagsEditor' import CustomTagsEditor from './CustomTagsEditor'
@@ -91,9 +92,18 @@ export default function CreateProjectModal({
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-secondary)' }}> <span style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-secondary)' }}>
风格配置 工程类型
</span> </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} /> <CustomTagsEditor style={style} onChange={setStyle} />
</div> </div>
+5 -2
View File
@@ -3,6 +3,7 @@ import { useProjectStore } from '../stores/project'
import { useTaskStore } from '../stores/task' import { useTaskStore } from '../stores/task'
import { mergeStyles } from '../utils/style' import { mergeStyles } from '../utils/style'
import StyleSelector from './StyleSelector' import StyleSelector from './StyleSelector'
import CustomTagsEditor from './CustomTagsEditor'
import PromptEditor from './PromptEditor' import PromptEditor from './PromptEditor'
const ASSET_TYPE_ICONS: Record<AssetType, JSX.Element> = { const ASSET_TYPE_ICONS: Record<AssetType, JSX.Element> = {
@@ -53,6 +54,7 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
setPrompt, setPrompt,
setAssetType, setAssetType,
toggleTaskStyle, toggleTaskStyle,
setTaskStyle,
setParams, setParams,
setEnableAI, setEnableAI,
setOptimizedPrompt, setOptimizedPrompt,
@@ -115,12 +117,13 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
</div> </div>
</div> </div>
{/* 任务风格覆盖 */} {/* 素材风格配置 */}
<div> <div>
<h3 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}> <h3 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}>
风格覆盖(可选) 素材风格
</h3> </h3>
<StyleSelector value={taskStyle} onChange={toggleTaskStyle} compact /> <StyleSelector value={taskStyle} onChange={toggleTaskStyle} compact />
<CustomTagsEditor style={taskStyle} onChange={setTaskStyle} />
</div> </div>
{/* 提示词(含 AI 优化) */} {/* 提示词(含 AI 优化) */}
+7 -8
View File
@@ -1,6 +1,6 @@
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import type { Project } from '../api/types' import type { Project } from '../api/types'
import { STYLE_CATEGORIES } from '../utils/style' import { PROJECT_TYPES, PROJECT_TYPE_KEY, getCustomTags } from '../utils/style'
interface ProjectCardProps { interface ProjectCardProps {
project: Project project: Project
@@ -23,12 +23,11 @@ function formatRelativeTime(dateStr: string): string {
export default function ProjectCard({ project, onDelete }: ProjectCardProps) { export default function ProjectCard({ project, onDelete }: ProjectCardProps) {
const navigate = useNavigate() const navigate = useNavigate()
const presetTags = STYLE_CATEGORIES.flatMap(cat => { const kvPairs = project.style.kvPairs
const value = project.style.kvPairs[cat.key] const projectTypeValue = kvPairs[PROJECT_TYPE_KEY]
if (!value) return [] const projectTypeLabel = PROJECT_TYPES.find(p => p.value === projectTypeValue)?.label
const option = cat.options.find(o => o.value === value) const customTags = getCustomTags(kvPairs).slice(0, 4)
return option ? [option.label] : [] const tags = projectTypeLabel ? [projectTypeLabel, ...customTags].slice(0, 4) : customTags
}).slice(0, 4)
return ( return (
<div <div
@@ -59,7 +58,7 @@ export default function ProjectCard({ project, onDelete }: ProjectCardProps) {
)} )}
<h3 style={{ fontSize: 16, marginBottom: 12 }}>{project.name}</h3> <h3 style={{ fontSize: 16, marginBottom: 12 }}>{project.name}</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{presetTags.map(tag => ( {tags.map(tag => (
<span <span
key={tag} key={tag}
style={{ style={{
+12 -2
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { useProjectStore } from '../stores/project' import { useProjectStore } from '../stores/project'
import { useToastStore } from '../stores/toast' import { useToastStore } from '../stores/toast'
import { PROJECT_TYPES, PROJECT_TYPE_KEY } from '../utils/style'
import StyleSelector from './StyleSelector' import StyleSelector from './StyleSelector'
import CustomTagsEditor from './CustomTagsEditor' import CustomTagsEditor from './CustomTagsEditor'
import Skeleton from './Skeleton' import Skeleton from './Skeleton'
@@ -73,9 +74,18 @@ export default function ProjectConfigPanel() {
<div style={{ flex: 1, overflow: 'auto', padding: '12px 16px' }}> <div style={{ flex: 1, overflow: 'auto', padding: '12px 16px' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-secondary)' }}> <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-secondary)' }}>
工程风格 工程类型
</div> </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} /> <CustomTagsEditor style={draftStyle} onChange={setDraftStyle} />
</div> </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' import styles from './GenerateForm.module.css'
interface PromptEditorProps { interface PromptEditorProps {
@@ -17,14 +17,25 @@ interface PromptEditorProps {
} }
function styleToDescription(kvPairs: Record<string, string>): string { 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] const value = kvPairs[cat.key]
if (!value) return null if (!value) continue
const option = cat.options.find(o => o.value === value) const option = cat.options.find(o => o.value === value)
return option ? `${cat.label}: ${option.label}` : null if (option) parts.push(`${cat.label}: ${option.label}`)
}) }
.filter(Boolean)
.join(', ') const customTags = getCustomTags(kvPairs)
if (customTags.length > 0) parts.push(`标签: ${customTags.join(', ')}`)
return parts.join(', ')
} }
export default function PromptEditor({ 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 { STYLE_CATEGORIES } from '../utils/style'
import styles from './StyleSelector.module.css' import styles from './StyleSelector.module.css'
@@ -5,16 +6,19 @@ interface StyleSelectorProps {
value: Record<string, string> value: Record<string, string>
onChange: (key: string, value: string) => void onChange: (key: string, value: string) => void
compact?: boolean compact?: boolean
categories?: StyleCategory[]
} }
export default function StyleSelector({ export default function StyleSelector({
value, value,
onChange, onChange,
compact, compact,
categories,
}: StyleSelectorProps) { }: StyleSelectorProps) {
const cats = categories ?? STYLE_CATEGORIES
return ( return (
<div className={`${styles.container} ${compact ? styles.compact : ''}`}> <div className={`${styles.container} ${compact ? styles.compact : ''}`}>
{STYLE_CATEGORIES.map(cat => ( {cats.map(cat => (
<div key={cat.key} className={styles.category}> <div key={cat.key} className={styles.category}>
<span className={styles.label}>{cat.label}</span> <span className={styles.label}>{cat.label}</span>
<div className={styles.options}> <div className={styles.options}>
+3
View File
@@ -20,6 +20,7 @@ interface TaskState {
setPrompt: (text: string) => void setPrompt: (text: string) => void
setAssetType: (type: AssetType) => void setAssetType: (type: AssetType) => void
toggleTaskStyle: (key: string, value: string) => void toggleTaskStyle: (key: string, value: string) => void
setTaskStyle: (style: Record<string, string>) => void
setParams: (params: Partial<TaskParams>) => void setParams: (params: Partial<TaskParams>) => void
setEnableAI: (enable: boolean) => void setEnableAI: (enable: boolean) => void
setOptimizedPrompt: (text: string | null) => void setOptimizedPrompt: (text: string | null) => void
@@ -56,6 +57,8 @@ export const useTaskStore = create<TaskState>((set, get) => ({
return { taskStyle: next, optimizedPrompt: null } return { taskStyle: next, optimizedPrompt: null }
}), }),
setTaskStyle: (style) => set({ taskStyle: style, optimizedPrompt: null }),
setParams: (params) => setParams: (params) =>
set(state => ({ params: { ...state.params, ...params } })), set(state => ({ params: { ...state.params, ...params } })),
+15
View File
@@ -44,6 +44,21 @@ export interface StyleCategory {
options: { value: string; label: string }[] 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[] = [ export const STYLE_CATEGORIES: StyleCategory[] = [
{ {
key: 'artStyle', key: 'artStyle',