fix: 工程类型与素材风格标签分离,支持双栏自定义标签
- 工程层面:新增 PROJECT_TYPES(2D平台跳跃/角色扮演等8种),替代原先与素材生成重复的风格标签栏 - 素材生成层面:StyleSelector 作为素材风格主配置项,新增 CustomTagsEditor 支持任务级自定义标签 - extractTags 合并工程类型、风格分类、工程自定义标签、任务自定义标签 - StyleSelector 支持 categories 参数复用 - 后端:spritesheet 拆分失败回退为单帧、GIF 上传为独立素材、AssetResponse 增加 metadata 字段
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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, ";")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -127,6 +127,7 @@ export interface AssetsResponse {
|
||||
frameHeight: number
|
||||
frameCount: number
|
||||
directions: number
|
||||
gifUrl?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 优化) */}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 } })),
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user