修复画板素材上传后的私有图片预览
Project CI / Repository checks (push) Successful in 48s
Project CI / Frontend tests (push) Failing after 27s
Project CI / Backend tests (push) Failing after 2m51s
Project CI / Native shell tests (push) Successful in 12m58s

分离上传后的签名预览地址与稳定持久化地址
保留素材落库回填后的即时签名预览
补充裸 generated 路径不得用于即时预览的回归测试
同步图片画布上传与持久化技术约束
This commit is contained in:
2026-07-31 15:46:08 +08:00
parent 9eae1a28c5
commit 48f9c8e690
3 changed files with 48 additions and 18 deletions
@@ -160,7 +160,7 @@
- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。
- 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits``sourceImageSrc` 提交给后端。
- 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。普通上传默认落入“上传素材”文件夹;素材库缺少该文件夹时,前端在首次普通上传前创建一次并复用,拖到指定文件夹或点击指定文件夹上传时仍进入目标文件夹。
- 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;上传视频同样在创建素材和图层前读取视频 metadata 宽高,保证单层下载或 ZIP 导出的真实视频文件重新导入后仍按文件自身尺寸入画布;仅在无法解析尺寸时才使用对应媒体兜底尺寸。
- 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;上传视频同样在创建素材和图层前读取视频 metadata 宽高,保证单层下载或 ZIP 导出的真实视频文件重新导入后仍按文件自身尺寸入画布;仅在无法解析尺寸时才使用对应媒体兜底尺寸。OSS 直传确认成功后,即时占位素材、即时画布图层和补充图片尺寸探测必须使用本次换签得到的 signed URL,不得把私有对象的裸 `/generated-*` 兼容路径交给浏览器读取;账号素材持久化仍只写 `legacyPublicPath``objectKey``assetObjectId`,禁止把会过期的 signed URL 写入资源或素材记录。
- 音频 / 视频素材卡和画布媒体图层必须提供稳定的非文字视觉预览:优先使用 `thumbnailSrc` / 视频 `poster`,没有真实首帧或音频封面时使用由媒体类型、素材名和地址派生的确定性视觉底图。视频图层使用原生 `<video controls preload="metadata" playsInline>` 播放,外层图层仍承接选择和拖拽语义;音频图层播放前继续通过 `/api/assets/read-url` 换签。画布素材导出按 `mediaType` 保留真实媒体格式:图片进入 `images/`,音频 / 视频进入 `media/`,文件扩展名从响应 MIME、`objectKey` 或源 URL 推断,不得把音频 / 视频导出成 PNG。
- 画布素材 ZIP 的 `metadata.json` 只保存前端信息弹窗和导出文件列表可见的展示快照:项目标题、导出时间、图层标题、文件路径、类型、生成输入、模型显示名、Task 短 ID、Object 显示值、Resolution / 时长和导出错误。导出的生成输入只保留用户实际填写或选择的内容;系统默认兜底提示词、固定工作流提示词、内置图标描述、UI 提取素材固定提示词等内置提示词即使存在于历史 `generationInputs`,也不得写入导出元数据。不得把 `projectId``layerId``resourceId``sourceAssetId``sourceResourceId`、原始 `prompt` / `actualPrompt` / `provider` 或画布坐标、锁定、隐藏等布局状态写入导出元数据;Object 字段仅沿用信息弹窗当前可见值。
- `生成角色动作` 的完成结果按序列帧素材处理:图层主 `src` 使用 `frames[0].imageSrc``mediaType` 固定为 `image-sequence``assetKind` 固定为 `character-animation`,完整帧列表写入 `imageSequenceFrames``previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。下载和 ZIP 导出必须因此得到序列帧 ZIP / frames 目录,不能回退为预览视频或首帧 PNG;后端抽帧后逐帧保存带绿幕源图,上传时消费并释放原帧字节,再用该对象的 600 秒 OSS URL 完成透明化并落盘透明帧素材。
@@ -8,7 +8,7 @@ import {
waitFor,
} from '@testing-library/react';
import { useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type {
@@ -195,7 +195,7 @@ function UploadWorkflowHarness({
{assets
.map(
(asset) =>
`${asset.id}:${asset.label}:${asset.folderId}:${asset.uploadStatus ?? 'ready'}:${asset.uploadMessage ?? '-'}:${asset.width}x${asset.height}`,
`${asset.id}:${asset.label}:${asset.folderId}:${asset.uploadStatus ?? 'ready'}:${asset.uploadMessage ?? '-'}:${asset.width}x${asset.height}:${asset.src}`,
)
.join('|')}
</span>
@@ -211,7 +211,7 @@ function UploadWorkflowHarness({
{layers
.map(
(layer) =>
`${layer.id}:${layer.title}:${layer.sourceAssetId}:${layer.x}:${layer.y}:${layer.width}x${layer.height}:${layer.objectKey ?? '-'}`,
`${layer.id}:${layer.title}:${layer.sourceAssetId}:${layer.x}:${layer.y}:${layer.width}x${layer.height}:${layer.objectKey ?? '-'}:${layer.src}`,
)
.join('|')}
</span>
@@ -380,6 +380,10 @@ function UploadWorkflowHarness({
}
describe('useImageCanvasUploadWorkflow', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
beforeEach(() => {
vi.clearAllMocks();
createEditorAssetMock.mockImplementation(async (input) => ({
@@ -531,6 +535,19 @@ describe('useImageCanvasUploadWorkflow', () => {
});
it('creates an uploading asset card, adds a canvas layer, keeps the sidebar, and patches the layer with the persisted asset id', async () => {
const measuredImageSources: string[] = [];
vi.stubGlobal(
'Image',
class {
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
set src(value: string) {
measuredImageSources.push(value);
}
},
);
const deferredAsset = createDeferred<{
assetId: string;
folderId: string;
@@ -549,10 +566,10 @@ describe('useImageCanvasUploadWorkflow', () => {
await waitFor(() => {
expect(screen.getByTestId('assets').textContent).toContain(
'upload-1:画布素材.png:folder-uploaded:uploading:上传中',
'upload-1:画布素材.png:folder-uploaded:uploading:上传中:420x315:https://signed.example.com/editor/image/画布素材.png',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-upload-1:画布素材.png:upload-1:-160:-107.5:420x315:generated-character-drafts/editor/asset-library/image/画布素材.png',
'layer-upload-1:画布素材.png:upload-1:-160:-107.5:420x315:generated-character-drafts/editor/asset-library/image/画布素材.png:https://signed.example.com/editor/image/画布素材.png',
);
});
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
@@ -568,6 +585,12 @@ describe('useImageCanvasUploadWorkflow', () => {
assetObjectId: 'assetobj-editor-image',
}),
);
expect(measuredImageSources).toContain(
'https://signed.example.com/editor/image/画布素材.png',
);
expect(measuredImageSources).not.toContain(
'/generated-character-drafts/editor/asset-library/image/画布素材.png',
);
expect(screen.getByTestId('sidebar').textContent).toBe('assets');
expect(screen.getByTestId('selected-layer').textContent).toBe(
'layer-upload-1',
@@ -577,7 +600,8 @@ describe('useImageCanvasUploadWorkflow', () => {
assetId: 'asset-persisted-canvas',
folderId: 'folder-uploaded',
label: '画布素材.png',
imageSrc: 'data:image/png;base64,Y2FudmFz',
imageSrc:
'/generated-character-drafts/editor/asset-library/image/画布素材.png',
width: 420,
height: 315,
sourceType: 'uploaded',
@@ -587,10 +611,10 @@ describe('useImageCanvasUploadWorkflow', () => {
await waitFor(() => {
expect(screen.getByTestId('assets').textContent).toContain(
'asset-persisted-canvas:画布素材.png:folder-uploaded:ready:-',
'asset-persisted-canvas:画布素材.png:folder-uploaded:ready:-:420x315:https://signed.example.com/editor/image/画布素材.png',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-upload-1:画布素材.png:asset-persisted-canvas:-160:-107.5',
'layer-upload-1:画布素材.png:asset-persisted-canvas:-160:-107.5:420x315:object-key-canvas:https://signed.example.com/editor/image/画布素材.png',
);
});
});
@@ -976,7 +976,8 @@ export function useImageCanvasUploadWorkflow({
}),
);
let imageSrc = '';
let previewSrc = '';
let persistedImageSrc = '';
let objectKey: string | null = null;
let assetObjectId: string | null = null;
let durationSeconds: number | undefined;
@@ -989,14 +990,15 @@ export function useImageCanvasUploadWorkflow({
));
}
const uploadedMedia = await uploadEditorMediaAssetFile(file, mediaType);
imageSrc = uploadedMedia.legacyPublicPath;
previewSrc = uploadedMedia.src;
persistedImageSrc = uploadedMedia.legacyPublicPath;
objectKey = uploadedMedia.objectKey;
assetObjectId = uploadedMedia.assetObjectId;
setAssets((currentAssets) =>
applyUploadAssetReadSuccess({
assets: currentAssets,
uploadAssetId: uploadedAsset.id,
imageSrc,
imageSrc: previewSrc,
}),
);
} catch {
@@ -1012,7 +1014,7 @@ export function useImageCanvasUploadWorkflow({
const nextLayer = createUploadCanvasLayer({
uploadIndex,
fileName: file.name,
imageSrc,
imageSrc: previewSrc,
objectKey,
assetObjectId,
mediaType,
@@ -1038,7 +1040,7 @@ export function useImageCanvasUploadWorkflow({
createEditorAsset({
folderId: uploadFolderId,
label: uploadedAsset.label,
imageSrc,
imageSrc: persistedImageSrc,
...(objectKey ? { objectKey } : {}),
...(assetObjectId ? { assetObjectId } : {}),
width: uploadedAsset.width,
@@ -1047,11 +1049,15 @@ export function useImageCanvasUploadWorkflow({
...(durationSeconds ? { durationSeconds } : {}),
})
.then((asset) => {
const persistedAssetWithPreview = {
...asset,
imageSrc: previewSrc,
};
setAssets((currentAssets) =>
applyPersistedUploadAsset({
assets: currentAssets,
uploadAssetId: uploadedAsset.id,
persistedAsset: asset,
persistedAsset: persistedAssetWithPreview,
}),
);
if (options.addToCanvas) {
@@ -1059,7 +1065,7 @@ export function useImageCanvasUploadWorkflow({
bindUploadLayerToPersistedAsset({
layers: currentLayers,
layerId: nextLayer.id,
persistedAsset: asset,
persistedAsset: persistedAssetWithPreview,
}),
);
}
@@ -1078,7 +1084,7 @@ export function useImageCanvasUploadWorkflow({
);
});
if (mediaType === 'image' && imageSrc) {
if (mediaType === 'image' && previewSrc) {
const uploadedImage = new Image();
uploadedImage.onload = () => {
const originalWidth =
@@ -1110,7 +1116,7 @@ export function useImageCanvasUploadWorkflow({
}),
);
};
uploadedImage.src = imageSrc;
uploadedImage.src = previewSrc;
}
},
[