Merge branch 'codex/editor-image-library-bugfix' into codex/editor-asset-library

This commit is contained in:
2026-06-19 16:24:21 +08:00
31 changed files with 1058 additions and 111 deletions
@@ -128,4 +128,20 @@ describe('ImageCanvasAssetRowView', () => {
screen.getByLabelText('素材账号素材A上传进度').getAttribute('value'),
).toBe('42');
});
it('uses icon thumbnails for audio assets instead of rendering media as images', () => {
renderAssetRow({
asset: createAsset({
label: '胜利音效.mp3',
src: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
mediaType: 'audio',
}),
});
expect(screen.getByText('音频')).toBeTruthy();
expect(screen.queryByRole('img', { name: '素材:胜利音效.mp3' })).toBeNull();
expect(
document.querySelector('.image-canvas-editor__asset-media-overlay'),
).toBeTruthy();
});
});
@@ -1,4 +1,4 @@
import { Check, Pencil, Trash2, X } from 'lucide-react';
import { Check, Music2, Pencil, Trash2, Video, X } from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import { PlatformTextField } from '../common/PlatformTextField';
@@ -65,6 +65,22 @@ export function ImageCanvasAssetRowView({
const isUploadingAsset = asset.uploadStatus === 'uploading';
const isFailedUpload = asset.uploadStatus === 'failed';
const uploadProgress = clamp(asset.uploadProgress ?? 0, 0, 100);
const mediaDetail =
asset.mediaType === 'audio'
? '音频'
: asset.mediaType === 'video'
? '视频'
: `${asset.width} x ${asset.height}`;
const mediaOverlay =
asset.mediaType === 'audio' ? (
<div className="image-canvas-editor__asset-media-overlay">
<Music2 className="h-5 w-5" />
</div>
) : asset.mediaType === 'video' ? (
<div className="image-canvas-editor__asset-media-overlay">
<Video className="h-5 w-5" />
</div>
) : undefined;
const titleNode = isRenaming ? (
<PlatformTextField
aria-label={`重命名素材${asset.label}`}
@@ -134,8 +150,8 @@ export function ImageCanvasAssetRowView({
<div data-asset-id={asset.id}>
<SidebarMediaItem
title={asset.label}
detail={`${asset.width} x ${asset.height}`}
imageSrc={asset.src}
detail={mediaDetail}
imageSrc={asset.mediaType === 'image' ? asset.src : ''}
imageAlt={`素材:${asset.label}`}
primaryLabel={
isUploadingAsset
@@ -182,7 +198,9 @@ export function ImageCanvasAssetRowView({
<div className="image-canvas-editor__asset-upload-overlay">
<span></span>
</div>
) : undefined
) : (
mediaOverlay
)
}
footerNode={
isUploadingAsset || isFailedUpload ? (
@@ -9,7 +9,7 @@ import {
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
ApiClientError,
@@ -38,6 +38,7 @@ const loadEditorProjectMock = vi.hoisted(() => vi.fn());
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
@@ -64,6 +65,10 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
};
});
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
}));
describe('ImageCanvasEditorView asset library integration', () => {
setupImageCanvasEditorViewTestLifecycle({
generateEditorImageMock,
@@ -84,6 +89,17 @@ describe('ImageCanvasEditorView asset library integration', () => {
saveEditorProjectLayoutMock,
});
beforeEach(() => {
uploadEditorMediaAssetFileMock.mockImplementation(
async (file: File, mediaType: 'video' | 'audio') => ({
src: `https://signed.example.com/editor/${mediaType}/${file.name}`,
legacyPublicPath: `/generated-character-drafts/editor/asset-library/${mediaType}/${file.name}`,
objectKey: `generated-character-drafts/editor/asset-library/${mediaType}/${file.name}`,
assetObjectId: `assetobj-editor-${mediaType}`,
}),
);
});
it('keeps only one default asset folder when the persisted library returns duplicated defaults', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
@@ -202,7 +218,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
await user.type(folderNameInput, '角色上传');
await user.click(screen.getByRole('button', { name: '保存素材文件夹' }));
const uploadInput = screen.getByLabelText('上传图片文件');
const uploadInput = screen.getByLabelText('上传媒体文件');
await user.click(screen.getByRole('button', { name: '上传到角色上传' }));
await userEvent.upload(
uploadInput,
@@ -346,7 +362,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
it('uploads multiple files as account-level assets without adding canvas layers', async () => {
render(<ImageCanvasEditorView />);
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
new File(['image-a'], '第一张.png', { type: 'image/png' }),
new File(['image-b'], '第二张.png', { type: 'image/png' }),
]);
@@ -364,6 +380,79 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull();
});
it('uploads MP3 and MP4 files through OSS before adding them to the asset library', async () => {
const createdMediaElements: HTMLMediaElement[] = [];
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => {
const element = originalCreateElement(tagName, options);
if (tagName === 'video' || tagName === 'audio') {
createdMediaElements.push(element as HTMLMediaElement);
}
return element;
});
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:editor-media'),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLMediaElement.prototype, 'duration', {
configurable: true,
value: 6,
});
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(
() => undefined,
);
render(<ImageCanvasEditorView />);
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
new File(['audio'], '胜利音效.mp3', { type: 'audio/mpeg' }),
new File(['video'], '开场动画.mp4', { type: 'video/mp4' }),
]);
act(() => {
createdMediaElements.forEach((element) =>
element.onloadedmetadata?.(new Event('loadedmetadata')),
);
});
await waitFor(() => {
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
expect.objectContaining({ name: '胜利音效.mp3' }),
'audio',
);
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
expect.objectContaining({ name: '开场动画.mp4' }),
'video',
);
});
await waitFor(() => {
expect(screen.getByRole('button', { name: '添加胜利音效.mp3' })).toBeTruthy();
expect(screen.getByRole('button', { name: '添加开场动画.mp4' })).toBeTruthy();
});
expect(createEditorAssetMock).toHaveBeenCalledWith(
expect.objectContaining({
label: '胜利音效.mp3',
imageSrc:
'/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
objectKey:
'generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
assetObjectId: 'assetobj-editor-audio',
}),
);
expect(createEditorAssetMock).toHaveBeenCalledWith(
expect.objectContaining({
label: '开场动画.mp4',
imageSrc:
'/generated-character-drafts/editor/asset-library/video/开场动画.mp4',
objectKey:
'generated-character-drafts/editor/asset-library/video/开场动画.mp4',
assetObjectId: 'assetobj-editor-video',
}),
);
});
it('opens login before uploading assets while logged out and resumes after login', async () => {
const openLoginModal = vi.fn();
const authValue = createAuthValue({ openLoginModal });
@@ -374,7 +463,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
</AuthUiContext.Provider>,
);
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
new File(['image'], '登录后上传.png', { type: 'image/png' }),
]);
@@ -429,7 +518,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
createEditorAssetMock.mockReturnValueOnce(deferredAsset.promise);
render(<ImageCanvasEditorView />);
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
new File(['image'], '素材上传进度.png', { type: 'image/png' }),
]);
@@ -489,7 +578,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
</AuthUiContext.Provider>,
);
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
new File(['image'], '过期登录.png', { type: 'image/png' }),
]);
@@ -1199,7 +1199,7 @@ describe('ImageCanvasEditorView generation integration', () => {
);
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
await userEvent.upload(
screen.getByLabelText('上传图片文件'),
screen.getByLabelText('上传媒体文件'),
new File(['icon-spec'], '图标规范.png', { type: 'image/png' }),
);
await waitFor(() => {
@@ -1689,7 +1689,7 @@ describe('ImageCanvasEditorView generation integration', () => {
),
);
await userEvent.upload(
screen.getByLabelText('上传图片文件'),
screen.getByLabelText('上传媒体文件'),
new File(['spec-reference'], '规范上传.png', { type: 'image/png' }),
);
@@ -1794,7 +1794,7 @@ describe('ImageCanvasEditorView generation integration', () => {
);
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
await userEvent.upload(
screen.getByLabelText('上传图片文件'),
screen.getByLabelText('上传媒体文件'),
new File(['reference'], '常规参考.png', { type: 'image/png' }),
);
await waitFor(() => {
@@ -50,6 +50,58 @@ describe('ImageCanvasEditorModel', () => {
expect(library.folders[0]?.id).toBe('project');
});
it('infers MP3 and MP4 asset media types from persisted paths', () => {
const library = normalizeAssetLibrary({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-audio',
folderId: 'project',
label: '胜利音效.mp3',
imageSrc: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
width: 420,
height: 120,
sourceType: 'uploaded',
objectKey:
'generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
},
{
assetId: 'asset-video',
folderId: 'project',
label: '开场动画.mp4',
imageSrc: '/generated-character-drafts/editor/asset-library/video/开场动画.mp4',
width: 560,
height: 315,
sourceType: 'uploaded',
objectKey:
'generated-character-drafts/editor/asset-library/video/开场动画.mp4',
},
],
});
expect(library.assets[0]).toMatchObject({ mediaType: 'audio' });
expect(library.assets[1]).toMatchObject({ mediaType: 'video' });
expect(
createLayerFromAsset(
library.assets[0]!,
1,
{ x: 0, y: 0, scale: 1 },
{ x: 300, y: 200 },
),
).toMatchObject({
mediaType: 'audio',
assetKind: 'sound-effect',
});
});
it('creates a layer from an account asset at the requested screen point', () => {
const asset: EditorAsset = {
id: 'asset-1',
@@ -166,30 +218,30 @@ describe('ImageCanvasEditorModel', () => {
originalWidth: 420,
originalHeight: 120,
zIndex: 5,
sourceType: 'generated',
mediaType: 'audio',
assetKind: 'sound-effect',
durationSeconds: 2.4,
},
new Map([
[
'resource-audio',
{
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
durationSeconds: 2.4,
},
],
]),
);
sourceType: 'generated',
mediaType: 'audio',
assetKind: 'sound-effect',
durationSeconds: 2.4,
},
new Map([
[
'resource-audio',
{
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
durationSeconds: 2.4,
},
],
]),
);
expect(hydrated).toMatchObject({
id: 'layer-audio',
src: '/generated-character-drafts/editor-audios/sfx.mp3',
mediaType: 'audio',
assetKind: 'sound-effect',
durationSeconds: 2.4,
src: '/generated-character-drafts/editor-audios/sfx.mp3',
mediaType: 'audio',
assetKind: 'sound-effect',
durationSeconds: 2.4,
});
});
});
it('restores audio generation dialogs from saved layout', () => {
const dialog: CanvasGenerationDialogState = {
@@ -114,12 +114,19 @@ export function createLayerFromAsset(
const worldCenterX = (safeScreenCenter.x - viewport.x) / safeScale;
const worldCenterY = (safeScreenCenter.y - viewport.y) / safeScale;
const offset = index * 34;
const assetKind: CanvasAssetKind | undefined =
asset.mediaType === 'video'
? 'video'
: asset.mediaType === 'audio'
? inferAudioAssetKindFromLabel(asset.label)
: undefined;
return {
id: `layer-${asset.id}-${index}`,
resourceId: `local-resource-${asset.id}-${index}`,
title: asset.label,
src: asset.src,
mediaType: asset.mediaType,
x: worldCenterX - width / 2 + offset,
y: worldCenterY - height / 2 + offset,
width,
@@ -137,6 +144,7 @@ export function createLayerFromAsset(
assetObjectId: asset.assetObjectId,
durationSeconds: asset.durationSeconds,
sourceAssetId: asset.id,
assetKind,
} satisfies CanvasLayer;
}
@@ -436,28 +444,56 @@ export function mapAssetLibrarySnapshot(
systemDefault: folder.systemDefault,
persisted: true,
})),
assets: library.assets.map((asset) => ({
id: asset.assetId,
label: asset.label,
src: asset.imageSrc,
width: asset.width,
height: asset.height,
folderId: asset.folderId,
sourceKind: 'uploaded',
sourceType: asset.sourceType,
persisted: true,
prompt: asset.prompt ?? undefined,
actualPrompt: asset.actualPrompt ?? undefined,
model: asset.model ?? undefined,
provider: asset.provider ?? undefined,
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
durationSeconds: asset.durationSeconds ?? undefined,
})),
assets: library.assets.map((asset) => {
const mediaType = inferEditorAssetMediaType(
asset.imageSrc,
asset.objectKey ?? undefined,
);
return {
id: asset.assetId,
label: asset.label,
src: asset.imageSrc,
mediaType,
width: asset.width,
height: asset.height,
folderId: asset.folderId,
sourceKind: 'uploaded',
sourceType: asset.sourceType,
persisted: true,
prompt: asset.prompt ?? undefined,
actualPrompt: asset.actualPrompt ?? undefined,
model: asset.model ?? undefined,
provider: asset.provider ?? undefined,
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
durationSeconds: asset.durationSeconds ?? undefined,
};
}),
};
}
export function inferEditorAssetMediaType(
imageSrc: string,
objectKey?: string | null,
): CanvasMediaType {
const source = `${objectKey ?? ''} ${imageSrc}`.toLowerCase();
const sourceWithoutQuery = source.split(/[?#]/u)[0] ?? source;
if (sourceWithoutQuery.includes('.mp4')) {
return 'video';
}
if (sourceWithoutQuery.includes('.mp3')) {
return 'audio';
}
return 'image';
}
function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind {
return label.includes('背景音乐') || label.toLowerCase().includes('music')
? 'background-music'
: 'sound-effect';
}
export function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) {
const mapped = mapAssetLibrarySnapshot(library);
let hasDefaultFolder = false;
@@ -261,9 +261,12 @@ export function setupImageCanvasEditorViewTestLifecycle({
folderId: input.folderId,
label: input.label,
imageSrc: input.imageSrc,
objectKey: input.objectKey,
assetObjectId: input.assetObjectId,
width: input.width,
height: input.height,
sourceType: input.sourceType,
durationSeconds: input.durationSeconds,
}));
createEditorAssetFolderMock.mockResolvedValue({
folderId: 'folder-role-persisted',
@@ -342,6 +342,7 @@ export function ImageCanvasEditorView() {
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
durationSeconds: layer.durationSeconds,
})
.then((asset) => {
setAssets((currentAssets) => [
@@ -352,6 +353,7 @@ export function ImageCanvasEditorView() {
id: asset.assetId,
label: asset.label,
src: asset.imageSrc,
mediaType: layer.mediaType,
width: asset.width,
height: asset.height,
folderId: asset.folderId,
@@ -365,6 +367,7 @@ export function ImageCanvasEditorView() {
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
durationSeconds: asset.durationSeconds ?? layer.durationSeconds,
},
]);
setAssetFolders((currentFolders) =>
@@ -2,6 +2,9 @@ export function isImageFile(file: File) {
return file.type.startsWith('image/');
}
export const EDITOR_ASSET_UPLOAD_ACCEPT =
'image/*,.mp3,.mp4,audio/mpeg,audio/mp3,video/mp4';
export const SEEDANCE_REFERENCE_ACCEPT = {
image:
'.jpg,.jpeg,.png,.webp,.bmp,.tiff,.tif,.gif,.heic,.heif,image/jpeg,image/png,image/webp,image/bmp,image/tiff,image/gif,image/heic,image/heif',
@@ -19,7 +22,7 @@ export function getEditorUploadAccept(target: string) {
if (target === 'video-reference-image') {
return SEEDANCE_REFERENCE_ACCEPT.image;
}
return 'image/*';
return EDITOR_ASSET_UPLOAD_ACCEPT;
}
export const SEEDANCE_REFERENCE_FILE_LIMITS = {
@@ -45,6 +48,8 @@ const SEEDANCE_IMAGE_EXTENSIONS = new Set([
]);
const SEEDANCE_VIDEO_EXTENSIONS = new Set(['mp4', 'mov']);
const SEEDANCE_AUDIO_EXTENSIONS = new Set(['wav', 'mp3']);
const EDITOR_ASSET_VIDEO_EXTENSIONS = new Set(['mp4']);
const EDITOR_ASSET_AUDIO_EXTENSIONS = new Set(['mp3']);
function getFileExtension(file: File) {
return file.name.split('.').pop()?.toLowerCase() ?? '';
@@ -77,10 +82,37 @@ export function isSeedanceReferenceAudioFile(file: File) {
file.type === 'audio/mpeg' ||
file.type === 'audio/mp3' ||
file.type === '') &&
hasAllowedExtension(file, SEEDANCE_AUDIO_EXTENSIONS)
hasAllowedExtension(file, SEEDANCE_AUDIO_EXTENSIONS)
);
}
export function isEditorAssetVideoFile(file: File) {
return (
(file.type === 'video/mp4' || file.type === '') &&
hasAllowedExtension(file, EDITOR_ASSET_VIDEO_EXTENSIONS)
);
}
export function isEditorAssetAudioFile(file: File) {
return (
(file.type === 'audio/mpeg' || file.type === 'audio/mp3' || file.type === '') &&
hasAllowedExtension(file, EDITOR_ASSET_AUDIO_EXTENSIONS)
);
}
export function resolveEditorAssetUploadMediaType(file: File) {
if (isEditorAssetVideoFile(file)) {
return 'video' as const;
}
if (isEditorAssetAudioFile(file)) {
return 'audio' as const;
}
if (isImageFile(file)) {
return 'image' as const;
}
return null;
}
export function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
@@ -10,6 +10,7 @@ import {
createGeneratedResultLayer,
createIconSpritesheetResultLayers,
createQuickEditResultLayer,
createVideoResultLayer,
} from './ImageCanvasGenerationLayerModel';
function createGenerated(overrides = {}) {
@@ -267,6 +268,8 @@ describe('ImageCanvasGenerationLayerModel', () => {
const layer = createAudioResultLayer({
generated: {
audioSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
objectKey: 'generated-character-drafts/editor-audios/sfx.mp3',
assetObjectId: 'assetobj-audio-1',
width: 420,
height: 120,
sourceType: 'generated',
@@ -315,10 +318,45 @@ describe('ImageCanvasGenerationLayerModel', () => {
model: 'audio1.0',
provider: 'VectorEngine',
taskId: 'sound-task-1',
objectKey: 'generated-character-drafts/editor-audios/sfx.mp3',
assetObjectId: 'assetobj-audio-1',
durationSeconds: 75,
});
});
it('creates video result layers with OSS asset identity', () => {
const layer = createVideoResultLayer({
generated: {
videoSrc: '/generated-character-drafts/editor-videos/video.mp4',
objectKey: 'generated-character-drafts/editor-videos/video.mp4',
assetObjectId: 'assetobj-video-1',
width: 1280,
height: 720,
sourceType: 'generated',
prompt: '开场动画',
actualPrompt: '开场动画',
model: 'seedance2.0-fast',
provider: 'VectorEngine',
taskId: 'video-task-1',
priceMudPoints: 40,
},
generatedIndex: 15,
title: '生成视频 15',
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
generationInputs: createGenerationInputs(),
});
expect(layer).toMatchObject({
id: 'layer-video-15',
src: '/generated-character-drafts/editor-videos/video.mp4',
mediaType: 'video',
assetKind: 'video',
objectKey: 'generated-character-drafts/editor-videos/video.mp4',
assetObjectId: 'assetobj-video-1',
});
});
it('creates character animation result layers with action metadata', () => {
const layer = createCharacterAnimationResultLayer({
generated: {
@@ -365,6 +365,8 @@ export function createVideoResultLayer({
model: generated.model,
provider: generated.provider,
taskId: generated.taskId,
objectKey: generated.objectKey,
assetObjectId: generated.assetObjectId,
generationInputs,
};
}
@@ -415,6 +417,8 @@ export function createAudioResultLayer({
model: generated.model,
provider: generated.provider,
taskId: generated.taskId,
objectKey: generated.objectKey,
assetObjectId: generated.assetObjectId,
durationSeconds: generated.durationSeconds ?? undefined,
generationInputs,
};
@@ -18,10 +18,12 @@ type PersistedUploadAsset = {
folderId: string;
label: string;
imageSrc: string;
mediaType?: CanvasMediaType;
width: number;
height: number;
objectKey?: string | null;
assetObjectId?: string | null;
durationSeconds?: number | null;
};
type GenerationReferenceUploadTarget =
| 'character-reference'
@@ -46,6 +48,24 @@ export const UPLOAD_LAYER_FALLBACK_SIZE = {
width: 420,
height: 315,
} as const;
export const UPLOAD_VIDEO_FALLBACK_SIZE = {
width: 560,
height: 315,
} as const;
export const UPLOAD_AUDIO_FALLBACK_SIZE = {
width: 420,
height: 120,
} as const;
export function resolveUploadFallbackSize(mediaType: CanvasMediaType) {
if (mediaType === 'video') {
return UPLOAD_VIDEO_FALLBACK_SIZE;
}
if (mediaType === 'audio') {
return UPLOAD_AUDIO_FALLBACK_SIZE;
}
return UPLOAD_LAYER_FALLBACK_SIZE;
}
export function resolveUploadFolderId({
assetFolders,
@@ -259,21 +279,28 @@ export function createUploadingAssetPlaceholder({
uploadIndex,
fileName,
folderId,
mediaType = 'image',
durationSeconds,
}: {
uploadIndex: number;
fileName?: string;
folderId: string;
mediaType?: CanvasMediaType;
durationSeconds?: number;
}): EditorAsset {
const fallbackSize = resolveUploadFallbackSize(mediaType);
return {
id: `upload-${uploadIndex}`,
label: fileName || '上传图片',
label: fileName || (mediaType === 'audio' ? '上传音频' : mediaType === 'video' ? '上传视频' : '上传图片'),
src: '',
width: UPLOAD_LAYER_FALLBACK_SIZE.width,
height: UPLOAD_LAYER_FALLBACK_SIZE.height,
mediaType,
width: fallbackSize.width,
height: fallbackSize.height,
folderId,
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: false,
durationSeconds,
uploadStatus: 'uploading',
uploadProgress: 8,
uploadMessage: '准备上传',
@@ -312,7 +339,7 @@ export function applyUploadAssetReadSuccess({
...asset,
src: imageSrc,
uploadProgress: 42,
uploadMessage: '读取图片',
uploadMessage: '读取素材',
}
: asset,
);
@@ -372,10 +399,12 @@ export function applyPersistedUploadAsset({
folderId: persistedAsset.folderId,
label: persistedAsset.label,
src: persistedAsset.imageSrc,
mediaType: persistedAsset.mediaType ?? asset.mediaType,
width: persistedAsset.width,
height: persistedAsset.height,
objectKey: persistedAsset.objectKey ?? undefined,
assetObjectId: persistedAsset.assetObjectId ?? undefined,
durationSeconds: persistedAsset.durationSeconds ?? asset.durationSeconds,
persisted: true,
uploadStatus: undefined,
uploadProgress: undefined,
@@ -442,8 +471,11 @@ export function bindUploadLayerToPersistedAsset({
? {
...layer,
sourceAssetId: persistedAsset.assetId,
mediaType: persistedAsset.mediaType ?? layer.mediaType,
objectKey: persistedAsset.objectKey ?? layer.objectKey,
assetObjectId: persistedAsset.assetObjectId ?? layer.assetObjectId,
durationSeconds:
persistedAsset.durationSeconds ?? layer.durationSeconds,
}
: layer,
);
@@ -471,6 +503,8 @@ export function createUploadCanvasLayer({
uploadIndex,
fileName,
imageSrc,
mediaType = 'image',
durationSeconds,
canvasPoint,
canvasSize,
viewport,
@@ -478,10 +512,13 @@ export function createUploadCanvasLayer({
uploadIndex: number;
fileName?: string;
imageSrc: string;
mediaType?: CanvasMediaType;
durationSeconds?: number;
canvasPoint?: CanvasPoint;
canvasSize: CanvasSize;
viewport: CanvasViewport;
}): CanvasLayer {
const fallbackSize = resolveUploadFallbackSize(mediaType);
const screenPoint = normalizeUploadScreenPoint({ canvasPoint, canvasSize });
const safeScale = viewport.scale > 0 ? viewport.scale : 1;
const worldCenterX = (screenPoint.x - viewport.x) / safeScale;
@@ -489,17 +526,25 @@ export function createUploadCanvasLayer({
return {
id: `layer-upload-${uploadIndex}`,
resourceId: `local-resource-upload-${uploadIndex}`,
title: fileName || '上传图片',
title: fileName || (mediaType === 'audio' ? '上传音频' : mediaType === 'video' ? '上传视频' : '上传图片'),
src: imageSrc,
x: worldCenterX - UPLOAD_LAYER_FALLBACK_SIZE.width / 2,
y: worldCenterY - UPLOAD_LAYER_FALLBACK_SIZE.height / 2,
width: UPLOAD_LAYER_FALLBACK_SIZE.width,
height: UPLOAD_LAYER_FALLBACK_SIZE.height,
originalWidth: UPLOAD_LAYER_FALLBACK_SIZE.width,
originalHeight: UPLOAD_LAYER_FALLBACK_SIZE.height,
mediaType,
assetKind:
mediaType === 'video'
? 'video'
: mediaType === 'audio'
? 'sound-effect'
: undefined,
x: worldCenterX - fallbackSize.width / 2,
y: worldCenterY - fallbackSize.height / 2,
width: fallbackSize.width,
height: fallbackSize.height,
originalWidth: fallbackSize.width,
originalHeight: fallbackSize.height,
zIndex: uploadIndex + 10,
sourceType: 'uploaded',
sourceAssetId: `upload-${uploadIndex}`,
durationSeconds,
};
}
@@ -518,6 +518,32 @@ function ImageCanvasAudioLayerCard({
);
}
function ImageCanvasVideoLayer({
layer,
mediaTransform,
}: {
layer: CanvasLayer;
mediaTransform?: string;
}) {
const { resolvedUrl } = useResolvedAssetReadUrl(layer.src, {
refreshKey: layer.taskId ?? layer.resourceId,
});
return (
<video
className="image-canvas-editor__layer-video"
src={resolvedUrl ?? layer.src}
controls
playsInline
preload="metadata"
aria-label={`画布视频:${layer.title}`}
style={{ transform: mediaTransform }}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
/>
);
}
export function ImageCanvasWorldView({
viewport,
snapGuide,
@@ -630,16 +656,9 @@ export function ImageCanvasWorldView({
isHovered={isHovered}
/>
) : layer.mediaType === 'video' ? (
<video
className="image-canvas-editor__layer-video"
src={layer.src}
controls
playsInline
preload="metadata"
aria-label={`画布视频:${layer.title}`}
style={{ transform: mediaTransform }}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
<ImageCanvasVideoLayer
layer={layer}
mediaTransform={mediaTransform}
/>
) : (
<img
@@ -648,23 +667,25 @@ export function ImageCanvasWorldView({
style={{ transform: mediaTransform }}
/>
)}
<span
role="button"
tabIndex={0}
aria-label={`素材标签:${kindLabel}`}
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${kindTagMeta.className} image-canvas-editor__kind-badge--beside-info image-canvas-editor__kind-badge--editable`}
style={inverseScaleStyle}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setOpenLayerKindMenuId((currentId) =>
currentId === layer.id ? null : layer.id,
);
}}
onKeyDown={handleLayerTagKeyboardActivation}
>
{kindLabel}
</span>
{kindLabel ? (
<span
role="button"
tabIndex={0}
aria-label={`素材标签:${kindLabel}`}
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${kindTagMeta.className} image-canvas-editor__kind-badge--beside-info image-canvas-editor__kind-badge--editable`}
style={inverseScaleStyle}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setOpenLayerKindMenuId((currentId) =>
currentId === layer.id ? null : layer.id,
);
}}
onKeyDown={handleLayerTagKeyboardActivation}
>
{kindLabel}
</span>
) : null}
<PlatformIconButton
asChild="spanButton"
variant="darkMini"
@@ -147,11 +147,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const addGeneratedLayersToCanvas = useCallback(
(nextLayers: CanvasLayer[]) => {
appendCanvasLayersWithResources(nextLayers);
nextLayers
.filter(
(layer) => layer.mediaType !== 'video' && layer.mediaType !== 'audio',
)
.forEach((layer) => persistGeneratedAsset?.(layer));
nextLayers.forEach((layer) => persistGeneratedAsset?.(layer));
},
[appendCanvasLayersWithResources, persistGeneratedAsset],
);
@@ -24,6 +24,7 @@ import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
const createEditorAssetMock = vi.hoisted(() => vi.fn());
const uploadEditorSeedanceReferenceFileMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
let createdMediaElements: HTMLMediaElement[] = [];
vi.mock('../../services/image-editor/editorProjectClient', async () => {
@@ -40,6 +41,10 @@ vi.mock('../../services/image-editor/editorReferenceUploadClient', () => ({
uploadEditorSeedanceReferenceFile: uploadEditorSeedanceReferenceFileMock,
}));
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
}));
function createDefaultFolder(): EditorAssetFolder {
return {
id: 'project',
@@ -282,6 +287,14 @@ describe('useImageCanvasUploadWorkflow', () => {
assetObjectId: `assetobj-${mediaType}`,
}),
);
uploadEditorMediaAssetFileMock.mockImplementation(
async (file: File, mediaType: 'video' | 'audio') => ({
src: `https://signed.example.com/editor/${mediaType}/${file.name}`,
legacyPublicPath: `/generated-character-drafts/editor/asset-library/${mediaType}/${file.name}`,
objectKey: `generated-character-drafts/editor/asset-library/${mediaType}/${file.name}`,
assetObjectId: `assetobj-editor-${mediaType}`,
}),
);
Object.defineProperty(HTMLMediaElement.prototype, 'duration', {
configurable: true,
value: 4,
@@ -8,6 +8,7 @@ import {
} from 'react';
import { ApiClientError } from '../../services/apiClient';
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
import { createEditorAsset } from '../../services/image-editor/editorProjectClient';
import { uploadEditorSeedanceReferenceFile } from '../../services/image-editor/editorReferenceUploadClient';
import type {
@@ -23,6 +24,7 @@ import type {
import {
SEEDANCE_REFERENCE_FILE_LIMITS,
isImageFile,
resolveEditorAssetUploadMediaType,
isSeedanceReferenceAudioFile,
isSeedanceReferenceImageFile,
isSeedanceReferenceVideoFile,
@@ -612,8 +614,9 @@ export function useImageCanvasUploadWorkflow({
const uploadAssetFile = useCallback(
async (file: File, options: UploadAssetFileOptions) => {
if (!isImageFile(file)) {
window.alert('请选择图片文件');
const mediaType = resolveEditorAssetUploadMediaType(file);
if (!mediaType) {
window.alert('请选择图片、MP3 或 MP4 文件');
return;
}
@@ -627,6 +630,7 @@ export function useImageCanvasUploadWorkflow({
uploadIndex,
fileName: file.name,
folderId: uploadFolderId,
mediaType,
});
setAssets((currentAssets) => [...currentAssets, uploadedAsset]);
setAssetFolders((currentFolders) =>
@@ -637,8 +641,22 @@ export function useImageCanvasUploadWorkflow({
);
let imageSrc = '';
let objectKey: string | null = null;
let assetObjectId: string | null = null;
let durationSeconds: number | undefined;
try {
imageSrc = await readImageFileAsDataUrl(file);
if (mediaType === 'image') {
imageSrc = await readImageFileAsDataUrl(file);
} else {
durationSeconds = await probeMediaDurationSeconds(
file,
mediaType,
).catch(() => undefined);
const uploadedMedia = await uploadEditorMediaAssetFile(file, mediaType);
imageSrc = uploadedMedia.legacyPublicPath;
objectKey = uploadedMedia.objectKey;
assetObjectId = uploadedMedia.assetObjectId;
}
setAssets((currentAssets) =>
applyUploadAssetReadSuccess({
assets: currentAssets,
@@ -660,6 +678,8 @@ export function useImageCanvasUploadWorkflow({
uploadIndex,
fileName: file.name,
imageSrc,
mediaType,
durationSeconds,
canvasPoint: options.canvasPoint,
canvasSize,
viewport,
@@ -680,9 +700,12 @@ export function useImageCanvasUploadWorkflow({
folderId: uploadFolderId,
label: uploadedAsset.label,
imageSrc,
...(objectKey ? { objectKey } : {}),
...(assetObjectId ? { assetObjectId } : {}),
width: uploadedAsset.width,
height: uploadedAsset.height,
sourceType: 'uploaded',
...(durationSeconds ? { durationSeconds } : {}),
})
.then((asset) => {
setAssets((currentAssets) =>
@@ -716,7 +739,7 @@ export function useImageCanvasUploadWorkflow({
);
});
if (imageSrc) {
if (mediaType === 'image' && imageSrc) {
const uploadedImage = new Image();
uploadedImage.onload = () => {
const originalWidth =
+10
View File
@@ -4047,6 +4047,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
background-size: 1rem 1rem;
}
.image-canvas-editor__asset-media-overlay {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #f8fafc;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.72), rgba(20, 83, 45, 0.52));
}
.image-canvas-editor__layer img,
.image-canvas-editor__layer video {
width: 100%;
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { requestJson } from '../apiClient';
import { getSignedAssetReadUrl } from '../assetReadUrlService';
import { uploadEditorMediaAssetFile } from './editorMediaAssetUploadClient';
vi.mock('../apiClient', () => ({
requestJson: vi.fn(),
}));
vi.mock('../assetReadUrlService', () => ({
getSignedAssetReadUrl: vi.fn(),
}));
const requestJsonMock = vi.mocked(requestJson);
const getSignedAssetReadUrlMock = vi.mocked(getSignedAssetReadUrl);
describe('editorMediaAssetUploadClient', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(Date, 'now').mockReturnValue(1771400000000);
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null, { status: 200 })),
);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('uploads editor MP4 assets through direct OSS upload and confirms object metadata', async () => {
requestJsonMock
.mockResolvedValueOnce({
upload: {
bucket: 'bucket',
host: 'https://oss.example.com',
objectKey:
'generated-character-drafts/editor/asset-library/video/demo.mp4',
legacyPublicPath:
'/generated-character-drafts/editor/asset-library/video/demo.mp4',
formFields: {
key: 'generated-character-drafts/editor/asset-library/video/demo.mp4',
policy: 'policy',
'x-oss-signature': 'signature',
},
},
})
.mockResolvedValueOnce({
assetObject: {
assetObjectId: 'assetobj-video',
objectKey:
'generated-character-drafts/editor/asset-library/video/demo.mp4',
assetKind: 'editor_uploaded_video',
},
});
getSignedAssetReadUrlMock.mockResolvedValueOnce(
'https://signed.example.com/demo.mp4',
);
const result = await uploadEditorMediaAssetFile(
new File(['video'], 'demo.mp4', { type: 'video/mp4' }),
'video',
);
const ticketBody = JSON.parse(
(requestJsonMock.mock.calls[0]?.[1] as RequestInit).body as string,
);
expect(ticketBody).toMatchObject({
legacyPrefix: 'generated-character-drafts',
pathSegments: ['editor', 'asset-library', 'video', '1771400000000'],
fileName: 'demo.mp4',
contentType: 'video/mp4',
access: 'private',
maxSizeBytes: 5,
successActionStatus: 200,
metadata: {
asset_kind: 'editor_uploaded_video',
editor_media_type: 'video',
},
});
expect(fetch).toHaveBeenCalledWith(
'https://oss.example.com',
expect.objectContaining({
method: 'POST',
body: expect.any(FormData),
}),
);
const confirmBody = JSON.parse(
(requestJsonMock.mock.calls[1]?.[1] as RequestInit).body as string,
);
expect(confirmBody).toMatchObject({
bucket: 'bucket',
objectKey: 'generated-character-drafts/editor/asset-library/video/demo.mp4',
contentType: 'video/mp4',
contentLength: 5,
assetKind: 'editor_uploaded_video',
accessPolicy: 'private',
entityId: 'editor-asset-library-video',
});
expect(getSignedAssetReadUrlMock).toHaveBeenCalledWith(
{
objectKey:
'generated-character-drafts/editor/asset-library/video/demo.mp4',
expireSeconds: 3600,
},
undefined,
{ bypassCache: true },
);
expect(result).toEqual({
src: 'https://signed.example.com/demo.mp4',
objectKey:
'generated-character-drafts/editor/asset-library/video/demo.mp4',
assetObjectId: 'assetobj-video',
legacyPublicPath:
'/generated-character-drafts/editor/asset-library/video/demo.mp4',
});
});
});
@@ -0,0 +1,139 @@
import { requestJson } from '../apiClient';
import { getSignedAssetReadUrl } from '../assetReadUrlService';
export type EditorMediaAssetUploadType = 'video' | 'audio';
type DirectUploadTicketResponse = {
upload: {
bucket: string;
host: string;
objectKey: string;
legacyPublicPath: string;
formFields: Record<string, string | null | undefined>;
};
};
type ConfirmAssetObjectResponse = {
assetObject: {
assetObjectId: string;
objectKey: string;
assetKind: string;
};
};
export type UploadedEditorMediaAsset = {
src: string;
objectKey: string;
assetObjectId: string;
legacyPublicPath: string;
};
const EDITOR_MEDIA_READ_EXPIRE_SECONDS = 60 * 60;
function resolveEditorMediaAssetKind(mediaType: EditorMediaAssetUploadType) {
return mediaType === 'video'
? 'editor_uploaded_video'
: 'editor_uploaded_audio';
}
function resolveFallbackFileName(mediaType: EditorMediaAssetUploadType) {
return mediaType === 'video' ? 'editor-video.mp4' : 'editor-audio.mp3';
}
function sanitizeEditorMediaFileName(
fileName: string,
mediaType: EditorMediaAssetUploadType,
) {
const trimmedName = fileName.trim();
return trimmedName || resolveFallbackFileName(mediaType);
}
async function postDirectUploadFile(
upload: DirectUploadTicketResponse['upload'],
file: File,
) {
const formData = new FormData();
Object.entries(upload.formFields).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, value);
}
});
formData.append('file', file, file.name);
const response = await fetch(upload.host, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传素材失败');
}
}
export async function uploadEditorMediaAssetFile(
file: File,
mediaType: EditorMediaAssetUploadType,
): Promise<UploadedEditorMediaAsset> {
const assetKind = resolveEditorMediaAssetKind(mediaType);
const fileName = sanitizeEditorMediaFileName(file.name, mediaType);
const contentType = file.type.trim() || 'application/octet-stream';
const ticket = await requestJson<DirectUploadTicketResponse>(
'/api/assets/direct-upload-tickets',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
legacyPrefix: 'generated-character-drafts',
pathSegments: ['editor', 'asset-library', mediaType, `${Date.now()}`],
fileName,
contentType,
access: 'private',
maxSizeBytes: file.size,
successActionStatus: 200,
metadata: {
asset_kind: assetKind,
editor_media_type: mediaType,
},
}),
},
'创建素材上传凭证失败',
);
await postDirectUploadFile(ticket.upload, file);
const confirmed = await requestJson<ConfirmAssetObjectResponse>(
'/api/assets/objects/confirm',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bucket: ticket.upload.bucket,
objectKey: ticket.upload.objectKey,
contentType,
contentLength: file.size,
assetKind,
accessPolicy: 'private',
entityId: `editor-asset-library-${mediaType}`,
}),
},
'确认素材资产失败',
);
// 中文注释:素材库立即预览使用短期 signed URL;持久化仍记录 legacy path 和 objectKey。
const signedUrl = await getSignedAssetReadUrl(
{
objectKey: confirmed.assetObject.objectKey,
expireSeconds: EDITOR_MEDIA_READ_EXPIRE_SECONDS,
},
undefined,
{ bypassCache: true },
);
return {
src: signedUrl,
objectKey: confirmed.assetObject.objectKey,
assetObjectId: confirmed.assetObject.assetObjectId,
legacyPublicPath: ticket.upload.legacyPublicPath,
};
}
@@ -229,6 +229,8 @@ export type EditorVideoGenerationInput = {
export type EditorVideoGenerationResult = {
videoSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
height: number;
sourceType: 'generated';
@@ -255,6 +257,8 @@ export type EditorBackgroundMusicGenerationInput = {
export type EditorAudioGenerationResult = {
audioSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
height: number;
sourceType: 'generated';