修复画板素材上传后的私有图片预览
分离上传后的签名预览地址与稳定持久化地址 保留素材落库回填后的即时签名预览 补充裸 generated 路径不得用于即时预览的回归测试 同步图片画布上传与持久化技术约束
This commit is contained in:
@@ -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;
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user