diff --git a/backend/internal/handler/generate.go b/backend/internal/handler/generate.go index f58705b..db6c8c0 100755 --- a/backend/internal/handler/generate.go +++ b/backend/internal/handler/generate.go @@ -47,8 +47,8 @@ type GenerateResponse struct { // AssetsResponse 素材列表响应。 type AssetsResponse struct { - Assets []model.AssetResponse `json:"assets"` - Metadata service.AssetMetadata `json:"metadata"` + Assets []model.AssetResponse `json:"assets"` + Metadata service.AssetMetadata `json:"metadata"` } // Generate 素材生成接口(异步)。 @@ -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 @@ -232,9 +234,10 @@ func GetAssets(c *gin.Context) { response := []model.AssetResponse{} for _, a := range assets { response = append(response, model.AssetResponse{ - Key: a.Key, - URL: storageSvc.GetSignedURL(a.Key), - Format: a.Format, + Key: a.Key, + URL: storageSvc.GetSignedURL(a.Key), + Format: a.Format, + Metadata: a.Metadata, }) } diff --git a/frontend/src/api/generate.ts b/frontend/src/api/generate.ts index bd568c4..65e2e47 100755 --- a/frontend/src/api/generate.ts +++ b/frontend/src/api/generate.ts @@ -19,18 +19,31 @@ export async function getTask(taskId: string): Promise { export async function getAssets(taskId: string): Promise { const resp = await get(`/api/v1/tasks/${taskId}/assets`) - return resp.assets.map((a, i) => ({ - 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, - metadata: { - frameWidth: resp.metadata.frameWidth, - frameHeight: resp.metadata.frameHeight, - frameCount: resp.metadata.frameCount, - directions: resp.metadata.directions, - }, - })) + 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, + }, + } + }) } diff --git a/frontend/src/api/mock.ts b/frontend/src/api/mock.ts index 5ad7ea3..e28219e 100755 --- a/frontend/src/api/mock.ts +++ b/frontend/src/api/mock.ts @@ -115,6 +115,8 @@ const MOCK_ASSETS: Asset[] = [ format: 'png', width: 256, height: 256, + assetType: 'frame', + index: 0, metadata: { frameWidth: 64, frameHeight: 64, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 03ae35f..daad4c4 100755 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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 diff --git a/frontend/src/components/AssetPreview.tsx b/frontend/src/components/AssetPreview.tsx index e983f78..26049fb 100755 --- a/frontend/src/components/AssetPreview.tsx +++ b/frontend/src/components/AssetPreview.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import type { Asset } from '../api/types' import EmptyState from './EmptyState' @@ -6,69 +7,245 @@ interface AssetPreviewProps { } export default function AssetPreview({ assets }: AssetPreviewProps) { + const [previewOpen, setPreviewOpen] = useState(false) + const [exportMenuOpen, setExportMenuOpen] = useState(false) + if (assets.length === 0) { return } + 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 (
- {assets.map(asset => ( -
- {/* 图片预览 */} -
+ {hasAnimation && gifUrl && ( + + )} + + {/* 导出菜单 */} +
+ + {exportMenuOpen && ( + <> +
setExportMenuOpen(false)} + />
+ {spritesheet && ( + + )} + +
+ + )} +
+
+ + {/* 帧网格 */} + {frames.length > 0 ? ( +
+ {frames.map(frame => ( +
+ {`帧 - )} -
+ + #{frame.index + 1} + +
+ ))} +
+ ) : ( + /* 非精灵表模式:直接展示所有非 preview 素材 */ +
+ {assets.filter(a => a.assetType !== 'preview').map(asset => ( +
+ {asset.id} +
+ ))} +
+ )} - {/* 元数据 */} -
- 尺寸: {asset.width} × {asset.height} - {asset.metadata.frameWidth && ( - 帧大小: {asset.metadata.frameWidth} × {asset.metadata.frameHeight} - )} - {asset.metadata.frameCount && 帧数: {asset.metadata.frameCount}} - {asset.metadata.directions && 方向: {asset.metadata.directions}} - 格式: {asset.format} + {/* 元数据 */} + {meta && ( +
+ {meta.frameWidth && meta.frameHeight && ( + 帧大小: {meta.frameWidth} x {meta.frameHeight} + )} + {meta.frameCount && 帧数: {meta.frameCount}} + {meta.directions && 方向: {meta.directions}} + {spritesheet && 精灵表: {spritesheet.width} x {spritesheet.height}} +
+ )} + + {/* 预览弹窗 */} + {previewOpen && gifUrl && ( +
setPreviewOpen(false)} + > +
e.stopPropagation()} + > +
+

动作帧预览

+ +
+ 预览动画 + + {meta?.frameCount || frames.length} 帧 + {meta?.directions ? ` x ${meta.directions} 方向` : ''} +
- ))} + )}
) } + +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) + } +} diff --git a/frontend/src/pages/ResultPage.tsx b/frontend/src/pages/ResultPage.tsx index a2dd75d..3012844 100755 --- a/frontend/src/pages/ResultPage.tsx +++ b/frontend/src/pages/ResultPage.tsx @@ -79,26 +79,41 @@ export default function ResultPage() { ) } - return ( -
-
-
- + if (task.status === 'failed') { + return ( +
+
+ ← 返回工程 -

生成结果

+

生成失败

- {assets.length > 0 && ( - +
+

+ {task.error || '未知错误'} +

+

+ 重试次数: {task.retryCount ?? 0} +

+
+
+ ) + } + + return ( +
+
+ + ← 返回工程 + +

生成结果

+ {task.prompt && ( +

+ 提示词: {task.prompt} +

)}
@@ -108,22 +123,3 @@ export default function ResultPage() {
) } - -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') - } - } -}