feat: 结果页添加帧预览、GIF动画和导出选项

- 后端: GetAssets 返回素材级别 metadata,区分 frame/preview/spritesheet 类型
- 后端: 上传时区分素材类型标签,GIF URL 独立追踪
- 前端: 结果详情页默认展示拆分后的单帧网格
- 前端: 添加动作帧 GIF 预览弹窗
- 前端: 添加导出下拉菜单(整张精灵表/自动切分帧)
- 前端: 失败任务展示错误信息和重试次数
This commit is contained in:
2026-05-25 19:41:55 +08:00
parent ab2b72e746
commit a518168762
6 changed files with 301 additions and 106 deletions
+8 -5
View File
@@ -136,7 +136,7 @@ func runPipelineBg(ctx context.Context, projectID, taskID string, req GenerateRe
updateTaskInDB(ctx, taskID, "saving", "format_adapter", "", 90)
// 上传素材并保存到数据库
var lastCDNURL string
var lastGIFCDNURL 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)
@@ -145,13 +145,15 @@ func runPipelineBg(ctx context.Context, projectID, taskID string, req GenerateRe
updateTaskInDB(ctx, taskID, "failed", "", "上传素材失败: "+err.Error(), 0)
return
}
lastCDNURL = cdnURL
var assetMeta map[string]interface{}
if a.Format == "gif" {
assetMeta = map[string]interface{}{"index": i, "type": "preview"}
lastGIFCDNURL = cdnURL
} else if strings.Contains(a.URL, "spritesheet") {
assetMeta = map[string]interface{}{"index": i, "type": "spritesheet"}
} else {
assetMeta = map[string]interface{}{"index": i}
assetMeta = map[string]interface{}{"index": i, "type": "frame"}
}
metadataJSON, _ := json.Marshal(assetMeta)
@@ -169,8 +171,8 @@ func runPipelineBg(ctx context.Context, projectID, taskID string, req GenerateRe
}
// GIF URL 替换为实际上传后的 CDN 地址
if output.Metadata.GIFURL != "" && lastCDNURL != "" {
output.Metadata.GIFURL = lastCDNURL
if output.Metadata.GIFURL != "" && lastGIFCDNURL != "" {
output.Metadata.GIFURL = lastGIFCDNURL
}
var fullMetadata string
@@ -235,6 +237,7 @@ func GetAssets(c *gin.Context) {
Key: a.Key,
URL: storageSvc.GetSignedURL(a.Key),
Format: a.Format,
Metadata: a.Metadata,
})
}
+15 -2
View File
@@ -19,18 +19,31 @@ export async function getTask(taskId: string): Promise<Task> {
export async function getAssets(taskId: string): Promise<Asset[]> {
const resp = await get<AssetsResponse>(`/api/v1/tasks/${taskId}/assets`)
return resp.assets.map((a, i) => ({
return resp.assets.map((a, i) => {
let assetType: Asset['assetType'] = 'frame'
let index = i
try {
const meta = JSON.parse(a.metadata)
if (meta.type) assetType = meta.type
if (typeof meta.index === 'number') index = meta.index
} catch { /* use defaults */ }
return {
id: `asset-${i}`,
key: a.key,
url: `/api/v1/assets/download?key=${encodeURIComponent(a.key)}`,
format: a.format,
width: resp.metadata.frameWidth,
height: resp.metadata.frameHeight,
assetType,
index,
metadata: {
frameWidth: resp.metadata.frameWidth,
frameHeight: resp.metadata.frameHeight,
frameCount: resp.metadata.frameCount,
directions: resp.metadata.directions,
gifUrl: resp.metadata.gifUrl,
},
}))
}
})
}
+2
View File
@@ -115,6 +115,8 @@ const MOCK_ASSETS: Asset[] = [
format: 'png',
width: 256,
height: 256,
assetType: 'frame',
index: 0,
metadata: {
frameWidth: 64,
frameHeight: 64,
+4
View File
@@ -87,12 +87,15 @@ export interface Asset {
format: string
width: number
height: number
assetType: 'frame' | 'preview' | 'spritesheet'
metadata: {
frameWidth?: number
frameHeight?: number
frameCount?: number
directions?: number
gifUrl?: string
}
index: number
}
// 生成请求 — 对应 POST /api/v1/generate
@@ -121,6 +124,7 @@ export interface AssetsResponse {
key: string
url: string
format: string
metadata: string // JSON: {"index":0,"type":"frame"|"preview"|"spritesheet"}
}[]
metadata: {
frameWidth: number
+217 -40
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import type { Asset } from '../api/types'
import EmptyState from './EmptyState'
@@ -6,26 +7,151 @@ interface AssetPreviewProps {
}
export default function AssetPreview({ assets }: AssetPreviewProps) {
const [previewOpen, setPreviewOpen] = useState(false)
const [exportMenuOpen, setExportMenuOpen] = useState(false)
if (assets.length === 0) {
return <EmptyState title="暂无素材" description="生成完成后,素材将在这里展示" />
}
const frames = assets.filter(a => a.assetType === 'frame')
const spritesheet = assets.find(a => a.assetType === 'spritesheet')
const preview = assets.find(a => a.assetType === 'preview')
const gifUrl = preview?.url || assets[0]?.metadata.gifUrl
const hasAnimation = frames.length > 1
const meta = assets[0]?.metadata
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{assets.map(asset => (
<div
key={asset.id}
className="card"
style={{ padding: 16 }}
{/* 工具栏 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
{hasAnimation && gifUrl && (
<button
className="btn-secondary"
onClick={() => setPreviewOpen(true)}
style={{ padding: '8px 16px', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}
title="查看动作帧 GIF 预览"
>
{/* 图片预览 */}
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M3 3v10l10-5z" />
</svg>
查看预览
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
(动作帧)
</span>
</button>
)}
{/* 导出菜单 */}
<div style={{ position: 'relative' }}>
<button
className="btn-primary"
onClick={() => setExportMenuOpen(!exportMenuOpen)}
style={{ padding: '8px 16px', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1L5 5h2v5h2V5h2L8 1zM2 9v3h12V9h-2v1H4V9H2z" />
</svg>
导出
</button>
{exportMenuOpen && (
<>
<div
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 9 }}
onClick={() => setExportMenuOpen(false)}
/>
<div
style={{
position: 'relative',
display: 'inline-block',
marginBottom: 12,
position: 'absolute',
top: '100%',
right: 0,
marginTop: 4,
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
boxShadow: 'var(--shadow-lg)',
zIndex: 10,
minWidth: 160,
overflow: 'hidden',
}}
>
{spritesheet && (
<button
onClick={() => { downloadAsset(spritesheet); setExportMenuOpen(false) }}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '10px 14px', fontSize: 13, background: 'none',
border: 'none', borderRadius: 0, color: 'var(--text-primary)',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--accent-dim)')}
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
>
整张导出(精灵表)
</button>
)}
<button
onClick={() => { downloadFrames(frames); setExportMenuOpen(false) }}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '10px 14px', fontSize: 13, background: 'none',
border: 'none', borderRadius: 0, color: 'var(--text-primary)',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--accent-dim)')}
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
>
自动切分导出({frames.length} 帧)
</button>
</div>
</>
)}
</div>
</div>
{/* 帧网格 */}
{frames.length > 0 ? (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 12,
}}
>
{frames.map(frame => (
<div
key={frame.id}
className="card"
style={{
padding: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
}}
>
<img
src={frame.url}
alt={`帧 ${frame.index}`}
style={{
width: '100%',
aspectRatio: '1',
objectFit: 'contain',
borderRadius: 'var(--radius)',
border: '1px solid var(--border)',
imageRendering: 'pixelated',
}}
/>
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>
#{frame.index + 1}
</span>
</div>
))}
</div>
) : (
/* 非精灵表模式:直接展示所有非 preview 素材 */
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{assets.filter(a => a.assetType !== 'preview').map(asset => (
<div key={asset.id} className="card" style={{ padding: 16 }}>
<img
src={asset.url}
alt={asset.id}
@@ -36,39 +162,90 @@ export default function AssetPreview({ assets }: AssetPreviewProps) {
border: '1px solid var(--border)',
}}
/>
{/* spritesheet 网格叠加 */}
{asset.metadata.frameWidth && asset.metadata.frameHeight && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: `
linear-gradient(to right, rgba(233,69,96,0.3) 1px, transparent 1px),
linear-gradient(to bottom, rgba(233,69,96,0.3) 1px, transparent 1px)
`,
backgroundSize: `${asset.metadata.frameWidth}px ${asset.metadata.frameHeight}px`,
borderRadius: 'var(--radius)',
pointerEvents: 'none',
}}
/>
)}
</div>
{/* 元数据 */}
<div style={{ display: 'flex', gap: 24, fontSize: 13, color: 'var(--text-secondary)' }}>
<span>尺寸: {asset.width} × {asset.height}</span>
{asset.metadata.frameWidth && (
<span>帧大小: {asset.metadata.frameWidth} × {asset.metadata.frameHeight}</span>
)}
{asset.metadata.frameCount && <span>帧数: {asset.metadata.frameCount}</span>}
{asset.metadata.directions && <span>方向: {asset.metadata.directions}</span>}
<span>格式: {asset.format}</span>
</div>
</div>
))}
</div>
)}
{/* 元数据 */}
{meta && (
<div style={{ display: 'flex', gap: 24, fontSize: 13, color: 'var(--text-secondary)', flexWrap: 'wrap' }}>
{meta.frameWidth && meta.frameHeight && (
<span>帧大小: {meta.frameWidth} x {meta.frameHeight}</span>
)}
{meta.frameCount && <span>帧数: {meta.frameCount}</span>}
{meta.directions && <span>方向: {meta.directions}</span>}
{spritesheet && <span>精灵表: {spritesheet.width} x {spritesheet.height}</span>}
</div>
)}
{/* 预览弹窗 */}
{previewOpen && gifUrl && (
<div
style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.7)', zIndex: 100,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
onClick={() => setPreviewOpen(false)}
>
<div
className="card"
style={{
maxWidth: '90vw', maxHeight: '90vh', padding: 24,
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16,
}}
onClick={e => e.stopPropagation()}
>
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%', alignItems: 'center' }}>
<h3 style={{ fontSize: 16 }}>动作帧预览</h3>
<button
className="btn-secondary"
onClick={() => setPreviewOpen(false)}
style={{ padding: '4px 12px', fontSize: 16, lineHeight: 1 }}
>
x
</button>
</div>
<img
src={gifUrl}
alt="预览动画"
style={{
maxWidth: '100%', maxHeight: '70vh',
borderRadius: 'var(--radius)',
imageRendering: 'pixelated',
}}
/>
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{meta?.frameCount || frames.length} 帧
{meta?.directions ? ` x ${meta.directions} 方向` : ''}
</span>
</div>
</div>
)}
</div>
)
}
async function downloadAsset(asset: Asset) {
try {
const res = await fetch(asset.url)
const blob = await res.blob()
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = `spritesheet.${asset.format}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(blobUrl)
} catch {
window.open(asset.url, '_blank')
}
}
async function downloadFrames(frames: Asset[]) {
for (const f of frames) {
await downloadAsset(f)
}
}
+27 -31
View File
@@ -79,26 +79,41 @@ export default function ResultPage() {
)
}
if (task.status === 'failed') {
return (
<div className="container page-enter" style={{ paddingTop: 40 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 24 }}>
<Link to={`/projects/${projectId}`} style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
&larr; 返回工程
</Link>
<h1 style={{ fontSize: 22 }}>生成失败</h1>
</div>
<div className="card" style={{ textAlign: 'center', padding: 40 }}>
<p style={{ color: 'var(--error)', marginBottom: 8 }}>
{task.error || '未知错误'}
</p>
<p style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
重试次数: {task.retryCount ?? 0}
</p>
</div>
</div>
)
}
return (
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ marginBottom: 24 }}>
<Link
to={`/projects/${projectId}`}
style={{ fontSize: 13, color: 'var(--text-secondary)' }}
>
&larr; 返回工程
</Link>
<h1 style={{ fontSize: 22 }}>生成结果</h1>
</div>
{assets.length > 0 && (
<button
className="btn-primary"
onClick={() => downloadAssets(assets)}
style={{ padding: '8px 20px', fontSize: 13 }}
>
下载全部素材
</button>
<h1 style={{ fontSize: 22, marginTop: 8 }}>生成结果</h1>
{task.prompt && (
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4 }}>
提示词: {task.prompt}
</p>
)}
</div>
@@ -108,22 +123,3 @@ export default function ResultPage() {
</div>
)
}
async function downloadAssets(assets: Asset[]) {
for (const a of assets) {
try {
const res = await fetch(a.url)
const blob = await res.blob()
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = `${a.id}.${a.format}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(blobUrl)
} catch {
window.open(a.url, '_blank')
}
}
}