feat: 结果页添加帧预览、GIF动画和导出选项
- 后端: GetAssets 返回素材级别 metadata,区分 frame/preview/spritesheet 类型 - 后端: 上传时区分素材类型标签,GIF URL 独立追踪 - 前端: 结果详情页默认展示拆分后的单帧网格 - 前端: 添加动作帧 GIF 预览弹窗 - 前端: 添加导出下拉菜单(整张精灵表/自动切分帧) - 前端: 失败任务展示错误信息和重试次数
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,6 +115,8 @@ const MOCK_ASSETS: Asset[] = [
|
||||
format: 'png',
|
||||
width: 256,
|
||||
height: 256,
|
||||
assetType: 'frame',
|
||||
index: 0,
|
||||
metadata: {
|
||||
frameWidth: 64,
|
||||
frameHeight: 64,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)' }}>
|
||||
← 返回工程
|
||||
</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)' }}
|
||||
>
|
||||
← 返回工程
|
||||
</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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user