合并编辑器素材修复分支
合并 codex/240e-editor-asset-fixes 到 codex/editor-asset-library 保留视频生成资源与素材返回 保留视频缩略图与生成输入元数据
This commit is contained in:
@@ -2,11 +2,17 @@
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { EditorAsset } from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasAssetRowView } from './ImageCanvasAssetRowView';
|
||||
|
||||
const useResolvedAssetReadUrlMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: useResolvedAssetReadUrlMock,
|
||||
}));
|
||||
|
||||
vi.mock('../common/PlatformMediaFrame', () => ({
|
||||
PlatformMediaFrame: ({
|
||||
src,
|
||||
@@ -32,6 +38,18 @@ vi.mock('../common/PlatformMediaFrame', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
useResolvedAssetReadUrlMock.mockImplementation(
|
||||
(source: string | null | undefined, options?: { objectKey?: string | null }) => ({
|
||||
resolvedUrl: options?.objectKey
|
||||
? `https://signed.example.com/${options.objectKey}`
|
||||
: (source ?? ''),
|
||||
isResolving: false,
|
||||
shouldResolve: Boolean(options?.objectKey),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
function createAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
@@ -187,16 +205,19 @@ describe('ImageCanvasAssetRowView', () => {
|
||||
asset: createAsset({
|
||||
label: '生成视频.mp4',
|
||||
src: '/generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
objectKey: 'generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
mediaType: 'video',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.getByText('视频')).toBeTruthy();
|
||||
expect(screen.queryByRole('img', { name: '素材:生成视频.mp4' })).toBeNull();
|
||||
expect(
|
||||
document.querySelector('.image-canvas-editor__media-preview--video')
|
||||
?.className,
|
||||
).toContain('image-canvas-editor__media-preview--variant-');
|
||||
const video = screen.getByLabelText(
|
||||
'视频素材预览:生成视频.mp4',
|
||||
) as HTMLVideoElement;
|
||||
expect(video.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes objectKey to image thumbnails in the asset library', () => {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Check, Music2, Pencil, Video, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import type { UploadFilesOptions } from './ImageCanvasAssetLibraryPanelView';
|
||||
import {
|
||||
EditorIconButton,
|
||||
SidebarMediaItem,
|
||||
@@ -21,7 +23,6 @@ import {
|
||||
getCanvasMediaPreviewClassName,
|
||||
getCanvasMediaPreviewMarker,
|
||||
} from './ImageCanvasMediaModel';
|
||||
import type { UploadFilesOptions } from './ImageCanvasAssetLibraryPanelView';
|
||||
|
||||
export type ImageCanvasAssetRowViewProps = {
|
||||
asset: EditorAsset;
|
||||
@@ -116,6 +117,25 @@ export function ImageCanvasAssetRowView({
|
||||
const mediaPreviewMarker = getCanvasMediaPreviewMarker(asset);
|
||||
const mediaPreviewClassName =
|
||||
getCanvasMediaPreviewClassName(mediaPreviewMarker);
|
||||
const { resolvedUrl: resolvedVideoPreviewUrl } = useResolvedAssetReadUrl(
|
||||
asset.mediaType === 'video' ? asset.src : '',
|
||||
{
|
||||
enabled: asset.mediaType === 'video' && !asset.thumbnailSrc?.trim(),
|
||||
objectKey: asset.mediaType === 'video' ? asset.objectKey : undefined,
|
||||
refreshKey: asset.id,
|
||||
},
|
||||
);
|
||||
const videoPreview =
|
||||
asset.mediaType === 'video' && !asset.thumbnailSrc?.trim() ? (
|
||||
<video
|
||||
className="image-canvas-editor__asset-video-preview"
|
||||
src={resolvedVideoPreviewUrl || undefined}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
aria-label={`视频素材预览:${asset.label}`}
|
||||
/>
|
||||
) : undefined;
|
||||
const mediaDetail =
|
||||
asset.mediaType === 'audio'
|
||||
? '音频'
|
||||
@@ -251,6 +271,7 @@ export function ImageCanvasAssetRowView({
|
||||
: (asset.thumbnailSrc ?? '')
|
||||
}
|
||||
objectKey={asset.mediaType === 'image' ? asset.objectKey : undefined}
|
||||
previewNode={videoPreview}
|
||||
titleNode={
|
||||
isUploadingAsset || isFailedUpload ? <span>{asset.label}</span> : titleNode
|
||||
}
|
||||
|
||||
@@ -94,7 +94,6 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!canvasClipboard?.layers.length}
|
||||
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
|
||||
>
|
||||
粘贴
|
||||
@@ -149,7 +148,6 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!canvasClipboard?.layers.length}
|
||||
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
|
||||
>
|
||||
粘贴
|
||||
|
||||
@@ -1142,6 +1142,50 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('pastes a system clipboard image from the canvas context menu', async () => {
|
||||
const originalClipboard = navigator.clipboard;
|
||||
const clipboardBlob = new Blob(['image'], { type: 'image/png' });
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
read: vi.fn(async () => [
|
||||
{
|
||||
types: ['image/png'],
|
||||
getType: vi.fn(async () => clipboardBlob),
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
try {
|
||||
render(<ImageCanvasEditorView />);
|
||||
await waitFor(() => {
|
||||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
|
||||
clientX: 360,
|
||||
clientY: 240,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('画布图片:剪贴板图片.png')).toBeTruthy();
|
||||
});
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '剪贴板图片.png',
|
||||
type: 'image/png',
|
||||
}),
|
||||
'image',
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps canvas clipboard paste ahead of system clipboard images', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -692,6 +692,7 @@ export function mapAssetLibrarySnapshot(
|
||||
const mediaType = inferEditorAssetMediaType(
|
||||
asset.imageSrc,
|
||||
asset.objectKey ?? undefined,
|
||||
asset.assetKind,
|
||||
);
|
||||
return {
|
||||
id: asset.assetId,
|
||||
@@ -725,7 +726,14 @@ export function mapAssetLibrarySnapshot(
|
||||
export function inferEditorAssetMediaType(
|
||||
imageSrc: string,
|
||||
objectKey?: string | null,
|
||||
assetKind?: string | null,
|
||||
): CanvasMediaType {
|
||||
if (assetKind === 'video') {
|
||||
return 'video';
|
||||
}
|
||||
if (assetKind === 'sound-effect' || assetKind === 'background-music') {
|
||||
return 'audio';
|
||||
}
|
||||
const source = `${objectKey ?? ''} ${imageSrc}`.toLowerCase();
|
||||
const sourceWithoutQuery = source.split(/[?#]/u)[0] ?? source;
|
||||
if (sourceWithoutQuery.includes('.mp4')) {
|
||||
|
||||
@@ -70,6 +70,7 @@ export type SidebarMediaItemProps = {
|
||||
primaryClassName?: string;
|
||||
actions?: ReactNode;
|
||||
titleNode?: ReactNode;
|
||||
previewNode?: ReactNode;
|
||||
previewOverlay?: ReactNode;
|
||||
footerNode?: ReactNode;
|
||||
draggable?: boolean;
|
||||
@@ -97,6 +98,7 @@ export function SidebarMediaItem({
|
||||
primaryClassName,
|
||||
actions,
|
||||
titleNode,
|
||||
previewNode,
|
||||
previewOverlay,
|
||||
footerNode,
|
||||
draggable,
|
||||
@@ -129,16 +131,18 @@ export function SidebarMediaItem({
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<PlatformMediaFrame
|
||||
src={imageSrc}
|
||||
objectKey={objectKey}
|
||||
alt={imageAlt}
|
||||
fallbackLabel={title}
|
||||
aspect="square"
|
||||
surface="none"
|
||||
className={thumbnailClassName}
|
||||
previewOverlay={previewOverlay}
|
||||
/>
|
||||
{previewNode ?? (
|
||||
<PlatformMediaFrame
|
||||
src={imageSrc}
|
||||
objectKey={objectKey}
|
||||
alt={imageAlt}
|
||||
fallbackLabel={title}
|
||||
aspect="square"
|
||||
surface="none"
|
||||
className={thumbnailClassName}
|
||||
previewOverlay={previewOverlay}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<div className={metaClassName}>
|
||||
{titleNode ?? <span>{title}</span>}
|
||||
|
||||
@@ -842,7 +842,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(contextMenuEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the blank canvas context menu with paste disabled, zoom, and fit all', () => {
|
||||
it('shows the blank canvas context menu with paste, zoom, and fit all', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const viewport = screen.getByLabelText('画布工作区');
|
||||
@@ -858,7 +858,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
name: '粘贴',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(within(menu).getByRole('menuitem', { name: '放大' })).toBeTruthy();
|
||||
expect(
|
||||
within(menu).getByRole('menuitem', { name: '显示画布所有元素' }),
|
||||
|
||||
@@ -114,6 +114,28 @@ function getClipboardImageFiles(clipboardData: DataTransfer | null) {
|
||||
.filter((file): file is File => file !== null && isImageFile(file));
|
||||
}
|
||||
|
||||
async function readSystemClipboardImageFiles() {
|
||||
const read = globalThis.navigator?.clipboard?.read;
|
||||
if (!read) {
|
||||
return [];
|
||||
}
|
||||
const items = await read();
|
||||
const imageFiles: File[] = [];
|
||||
for (const item of items) {
|
||||
const imageType = item.types.find((type) => type.startsWith('image/'));
|
||||
if (!imageType) {
|
||||
continue;
|
||||
}
|
||||
const blob = await item.getType(imageType);
|
||||
imageFiles.push(
|
||||
new File([blob], `剪贴板图片.${imageType.split('/')[1] ?? 'png'}`, {
|
||||
type: imageType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return imageFiles;
|
||||
}
|
||||
|
||||
export function ImageCanvasEditorView() {
|
||||
const authUi = useAuthUi();
|
||||
const [, setGenerationPricingVersion] = useState(0);
|
||||
@@ -1279,6 +1301,24 @@ export function ImageCanvasEditorView() {
|
||||
setShiftPressed,
|
||||
});
|
||||
|
||||
const pasteCanvasOrSystemClipboard = useCallback(
|
||||
(canvasPoint?: { x: number; y: number }) => {
|
||||
if (pasteCanvasClipboard(canvasPoint)) {
|
||||
return;
|
||||
}
|
||||
void readSystemClipboardImageFiles()
|
||||
.then((imageFiles) => {
|
||||
if (imageFiles.length) {
|
||||
addUploadedFiles(imageFiles, { addToCanvas: true, canvasPoint });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 系统剪贴板读取受浏览器权限限制,失败时保留原生粘贴路径。
|
||||
});
|
||||
},
|
||||
[addUploadedFiles, pasteCanvasClipboard],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClipboardPaste = (event: ClipboardEvent) => {
|
||||
if (isEditablePasteTarget(event.target)) {
|
||||
@@ -1527,7 +1567,7 @@ export function ImageCanvasEditorView() {
|
||||
onQuickEditSelectionPointerEnd: endQuickEditSelectionPointer,
|
||||
onOpenCharacterAnimationPanel: openCharacterAnimationPanel,
|
||||
onDownloadLayer: exportLayerImage,
|
||||
onPasteCanvasClipboard: pasteCanvasClipboard,
|
||||
onPasteCanvasClipboard: pasteCanvasOrSystemClipboard,
|
||||
onCopyContextLayers: copyContextLayers,
|
||||
onDuplicateContextLayers: duplicateContextLayers,
|
||||
onMoveContextLayers: moveContextLayers,
|
||||
|
||||
@@ -426,9 +426,25 @@ export function probeMediaDurationSeconds(
|
||||
file: File,
|
||||
mediaType: 'video' | 'audio',
|
||||
) {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
if (typeof document === 'undefined' || typeof URL === 'undefined') {
|
||||
resolve(Number.NaN);
|
||||
return probeMediaFileMetadata(file, mediaType).then(
|
||||
(metadata) => metadata.durationSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
export function probeMediaFileMetadata(
|
||||
file: File,
|
||||
mediaType: 'video' | 'audio',
|
||||
) {
|
||||
return new Promise<{
|
||||
durationSeconds: number;
|
||||
dimensions: ImageFileDimensions | null;
|
||||
}>((resolve, reject) => {
|
||||
if (
|
||||
typeof document === 'undefined' ||
|
||||
typeof URL === 'undefined' ||
|
||||
typeof URL.createObjectURL !== 'function'
|
||||
) {
|
||||
resolve({ durationSeconds: Number.NaN, dimensions: null });
|
||||
return;
|
||||
}
|
||||
const element =
|
||||
@@ -443,13 +459,20 @@ export function probeMediaDurationSeconds(
|
||||
};
|
||||
element.preload = 'metadata';
|
||||
element.onloadedmetadata = () => {
|
||||
const duration = element.duration;
|
||||
const dimensions =
|
||||
mediaType === 'video'
|
||||
? normalizeImageFileDimensions({
|
||||
width: (element as HTMLVideoElement).videoWidth,
|
||||
height: (element as HTMLVideoElement).videoHeight,
|
||||
})
|
||||
: null;
|
||||
const durationSeconds = element.duration;
|
||||
cleanup();
|
||||
resolve(duration);
|
||||
resolve({ durationSeconds, dimensions });
|
||||
};
|
||||
element.onerror = () => {
|
||||
cleanup();
|
||||
reject(new Error('读取媒体时长失败'));
|
||||
reject(new Error('读取媒体元数据失败'));
|
||||
};
|
||||
element.src = objectUrl;
|
||||
});
|
||||
|
||||
@@ -415,14 +415,16 @@ export function createVideoResultLayer({
|
||||
frame && frame.height > 0
|
||||
? frame.y + frame.height / 2 - height / 2
|
||||
: undefined;
|
||||
const resource = generated.resource;
|
||||
const asset = generated.asset;
|
||||
|
||||
return {
|
||||
id: `layer-video-${generatedIndex}`,
|
||||
resourceId: `local-resource-video-${generatedIndex}`,
|
||||
resourceId: resource?.resourceId ?? `local-resource-video-${generatedIndex}`,
|
||||
title,
|
||||
src: generated.videoSrc,
|
||||
mediaType: 'video',
|
||||
assetKind,
|
||||
assetKind: (resource?.assetKind as CanvasLayer['assetKind'] | undefined) ?? assetKind,
|
||||
x: frameX ?? worldCenter.x - width / 2,
|
||||
y: frameY ?? worldCenter.y - height / 2,
|
||||
width,
|
||||
@@ -436,14 +438,15 @@ export function createVideoResultLayer({
|
||||
model: generated.model,
|
||||
provider: generated.provider,
|
||||
taskId: generated.taskId,
|
||||
objectKey: generated.objectKey,
|
||||
assetObjectId: generated.assetObjectId,
|
||||
objectKey: resource?.objectKey ?? generated.objectKey,
|
||||
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
|
||||
thumbnailSrc: generated.thumbnailSrc ?? generated.asset?.thumbnailSrc ?? undefined,
|
||||
sourceResourceId: sourceLayer?.resourceId,
|
||||
sourceAssetId: asset?.assetId,
|
||||
groupId: sourceLayer?.groupId,
|
||||
generationInputs,
|
||||
generationInputs: resource?.generationInputs ?? generationInputs,
|
||||
durationSeconds: generated.durationSeconds,
|
||||
generatedAssetSnapshot: generated.asset ?? undefined,
|
||||
generatedAssetSnapshot: asset ?? generated.asset ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ export function resolvePointerSelection({
|
||||
isMultiSelectGesture: boolean;
|
||||
}) {
|
||||
if (!isMultiSelectGesture) {
|
||||
if (selectedIds.includes(targetId)) {
|
||||
return selectedIds;
|
||||
}
|
||||
return [targetId];
|
||||
}
|
||||
if (!selectedIds.includes(targetId)) {
|
||||
|
||||
@@ -181,6 +181,58 @@ describe('ImageCanvasStageInteractionModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the existing mixed selection when dragging an already selected layer', () => {
|
||||
const selectedGenerationId = getCanvasGenerationSelectionId('dialog-1');
|
||||
const layers = [
|
||||
createLayer({ id: 'a', x: 40, y: 50 }),
|
||||
createLayer({ id: 'b', x: 120, y: 150 }),
|
||||
];
|
||||
const generationDialogs: CanvasGenerationDialogState[] = [
|
||||
{
|
||||
id: 'dialog-1',
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
placeholder: {
|
||||
x: 400,
|
||||
y: 180,
|
||||
width: 320,
|
||||
height: 320,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
createLayerDragStart({
|
||||
layer: layers[1]!,
|
||||
layers,
|
||||
generationDialogs,
|
||||
selectedLayerIds: ['a', 'b', selectedGenerationId],
|
||||
isMultiSelectGesture: false,
|
||||
pointerId: 4,
|
||||
pointer: { x: 300, y: 260 },
|
||||
viewportScale: 1.25,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
selectedLayerIds: ['a', 'b', selectedGenerationId],
|
||||
selectedLayerId: 'b',
|
||||
dragState: expect.objectContaining({
|
||||
layerId: 'b',
|
||||
layerIds: ['a', 'b'],
|
||||
generationDialogIds: ['dialog-1'],
|
||||
startLayers: [
|
||||
{ id: 'a', x: 40, y: 50 },
|
||||
{ id: 'b', x: 120, y: 150 },
|
||||
],
|
||||
startGenerationFrames: [{ id: 'dialog-1', x: 400, y: 180 }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('closes canvas generation composers when clicking existing layers', () => {
|
||||
const generatedDialog: GenerateDialogState = {
|
||||
id: 'dialog-1',
|
||||
@@ -365,4 +417,66 @@ describe('ImageCanvasStageInteractionModel', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the existing mixed selection when dragging an already selected generation frame', () => {
|
||||
const dialog: CanvasGenerationDialogState = {
|
||||
id: 'dialog-1',
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
placeholder: {
|
||||
x: 200,
|
||||
y: 160,
|
||||
width: 420,
|
||||
height: 420,
|
||||
originalWidth: 2048,
|
||||
originalHeight: 2048,
|
||||
},
|
||||
};
|
||||
const anotherDialog: CanvasGenerationDialogState = {
|
||||
...dialog,
|
||||
id: 'dialog-2',
|
||||
placeholder: {
|
||||
...dialog.placeholder!,
|
||||
x: 680,
|
||||
y: 160,
|
||||
},
|
||||
};
|
||||
const layers = [createLayer({ id: 'layer-a', x: 40, y: 50 })];
|
||||
|
||||
expect(
|
||||
createGenerationFrameSelectionStart({
|
||||
dialog: anotherDialog,
|
||||
layers,
|
||||
generationDialogs: [dialog, anotherDialog],
|
||||
selectedLayerIds: [
|
||||
'layer-a',
|
||||
getCanvasGenerationSelectionId('dialog-1'),
|
||||
getCanvasGenerationSelectionId('dialog-2'),
|
||||
],
|
||||
isMultiSelectGesture: false,
|
||||
pointerId: 12,
|
||||
pointer: { x: 360, y: 260 },
|
||||
viewportScale: 1,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
selectedLayerIds: [
|
||||
'layer-a',
|
||||
getCanvasGenerationSelectionId('dialog-1'),
|
||||
getCanvasGenerationSelectionId('dialog-2'),
|
||||
],
|
||||
dragState: expect.objectContaining({
|
||||
dialogId: 'dialog-2',
|
||||
dialogIds: ['dialog-1', 'dialog-2'],
|
||||
layerIds: ['layer-a'],
|
||||
startFrames: [
|
||||
{ id: 'dialog-1', x: 200, y: 160 },
|
||||
{ id: 'dialog-2', x: 680, y: 160 },
|
||||
],
|
||||
startLayers: [{ id: 'layer-a', x: 40, y: 50 }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,6 +154,19 @@ describe('ImageCanvasUploadModel', () => {
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
});
|
||||
expect(
|
||||
createUploadingAssetPlaceholder({
|
||||
uploadIndex: 9,
|
||||
fileName: 'intro.mp4',
|
||||
folderId: 'characters',
|
||||
mediaType: 'video',
|
||||
imageDimensions: { width: 1280, height: 720 },
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: 'upload-9',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the target upload folder without changing other folders', () => {
|
||||
@@ -520,6 +533,31 @@ describe('ImageCanvasUploadModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates upload video layers with measured video dimensions', () => {
|
||||
const layer = createUploadCanvasLayer({
|
||||
uploadIndex: 1,
|
||||
fileName: '画布视频.mp4',
|
||||
imageSrc: '/generated/video.mp4',
|
||||
mediaType: 'video',
|
||||
imageDimensions: { width: 1280, height: 720 },
|
||||
canvasPoint: { x: 110, y: 120 },
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
viewport: { x: 10, y: 20, scale: 2 },
|
||||
});
|
||||
|
||||
expect(layer).toMatchObject({
|
||||
title: '画布视频.mp4',
|
||||
mediaType: 'video',
|
||||
assetKind: 'video',
|
||||
x: -590,
|
||||
y: -310,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
originalWidth: 1280,
|
||||
originalHeight: 720,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses viewport center fallback for invalid upload points', () => {
|
||||
const layer = createUploadCanvasLayer({
|
||||
uploadIndex: 2,
|
||||
|
||||
@@ -310,7 +310,7 @@ export function createUploadingAssetPlaceholder({
|
||||
}): EditorAsset {
|
||||
const fallbackSize = resolveUploadFallbackSize(mediaType);
|
||||
const size =
|
||||
mediaType === 'image' && imageDimensions
|
||||
mediaType !== 'audio' && imageDimensions
|
||||
? resolveLayerResolutionSize(
|
||||
imageDimensions.width,
|
||||
imageDimensions.height,
|
||||
@@ -562,7 +562,7 @@ export function createUploadCanvasLayer({
|
||||
}): CanvasLayer {
|
||||
const fallbackSize = resolveUploadFallbackSize(mediaType);
|
||||
const layerSize =
|
||||
mediaType === 'image' && imageDimensions
|
||||
mediaType !== 'audio' && imageDimensions
|
||||
? resolveLayerResolutionSize(
|
||||
imageDimensions.width,
|
||||
imageDimensions.height,
|
||||
|
||||
@@ -1489,6 +1489,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
generationInputs: submissionPlan.result.generationInputs,
|
||||
assetFolderId,
|
||||
assetLabel: submissionPlan.result.title,
|
||||
assetKind: 'video',
|
||||
...(projectId && canvasCompletionPlaceholder
|
||||
? {
|
||||
canvasCompletion: {
|
||||
|
||||
@@ -102,6 +102,21 @@ function createSizedPngFile(name: string, width: number, height: number) {
|
||||
return new File([bytes], name, { type: 'image/png' });
|
||||
}
|
||||
|
||||
function setVideoElementSize(
|
||||
element: HTMLMediaElement | undefined,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
Object.defineProperty(element, 'videoWidth', {
|
||||
configurable: true,
|
||||
value: width,
|
||||
});
|
||||
Object.defineProperty(element, 'videoHeight', {
|
||||
configurable: true,
|
||||
value: height,
|
||||
});
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -248,6 +263,20 @@ function UploadWorkflowHarness({
|
||||
>
|
||||
上传原始尺寸到画布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.addUploadedFiles(
|
||||
[new File(['video'], '画布视频.mp4', { type: 'video/mp4' })],
|
||||
{
|
||||
addToCanvas: true,
|
||||
canvasPoint: { x: 110, y: 120 },
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
上传视频到画布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -587,6 +616,44 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the uploaded video resolution before creating the canvas layer and persisted asset', async () => {
|
||||
render(<UploadWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '上传视频到画布' }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
createdMediaElements.some(
|
||||
(element) => element.tagName.toLowerCase() === 'video',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
createdMediaElements
|
||||
.filter((element) => element.tagName.toLowerCase() === 'video')
|
||||
.forEach((element) => setVideoElementSize(element, 1280, 720));
|
||||
act(() => {
|
||||
createdMediaElements.forEach((element) =>
|
||||
element.onloadedmetadata?.(new Event('loadedmetadata')),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEditorAssetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: '画布视频.mp4',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
durationSeconds: 4,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('assets').textContent).toContain(
|
||||
'persisted-画布视频.mp4:画布视频.mp4:folder-uploaded:ready:-:1280x720',
|
||||
);
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-upload-1:画布视频.mp4:persisted-画布视频.mp4:-590:-310:1280x720',
|
||||
);
|
||||
});
|
||||
|
||||
it('marks upload cards as failed and reopens login on auth errors returned by asset creation', async () => {
|
||||
const openEditorLoginModal = vi.fn();
|
||||
createEditorAssetMock.mockRejectedValueOnce(
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
isSeedanceReferenceImageFile,
|
||||
isSeedanceReferenceVideoFile,
|
||||
probeImageFileDimensions,
|
||||
probeMediaFileMetadata,
|
||||
probeMediaDurationSeconds,
|
||||
readFileAsDataUrl,
|
||||
readImageFileAsDataUrl,
|
||||
@@ -944,10 +945,18 @@ export function useImageCanvasUploadWorkflow({
|
||||
}
|
||||
const uploadIndex = options.uploadIndex;
|
||||
let imageDimensions: { width: number; height: number } | null = null;
|
||||
let mediaMetadata: Awaited<
|
||||
ReturnType<typeof probeMediaFileMetadata>
|
||||
> | null = null;
|
||||
if (mediaType === 'image') {
|
||||
imageDimensions = await probeImageFileDimensions(file).catch(
|
||||
() => null,
|
||||
);
|
||||
} else if (mediaType === 'video') {
|
||||
mediaMetadata = await probeMediaFileMetadata(file, 'video').catch(
|
||||
() => null,
|
||||
);
|
||||
imageDimensions = mediaMetadata?.dimensions ?? null;
|
||||
}
|
||||
const uploadedAsset = createUploadingAssetPlaceholder({
|
||||
uploadIndex,
|
||||
@@ -970,10 +979,11 @@ export function useImageCanvasUploadWorkflow({
|
||||
let durationSeconds: number | undefined;
|
||||
try {
|
||||
if (mediaType !== 'image') {
|
||||
durationSeconds = await probeMediaDurationSeconds(
|
||||
file,
|
||||
mediaType,
|
||||
).catch(() => undefined);
|
||||
durationSeconds =
|
||||
mediaMetadata?.durationSeconds ??
|
||||
(await probeMediaDurationSeconds(file, mediaType).catch(
|
||||
() => undefined,
|
||||
));
|
||||
}
|
||||
const uploadedMedia = await uploadEditorMediaAssetFile(file, mediaType);
|
||||
imageSrc = uploadedMedia.legacyPublicPath;
|
||||
|
||||
@@ -4745,6 +4745,15 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-video-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 0.35rem;
|
||||
background: #0f172a;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-canvas-editor__media-preview {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
|
||||
@@ -350,6 +350,8 @@ export type EditorVideoGenerationResult = {
|
||||
taskId: string;
|
||||
durationSeconds: number;
|
||||
priceMudPoints?: number;
|
||||
resource?: EditorProjectResourceSnapshot | null;
|
||||
asset?: EditorAssetSnapshot | null;
|
||||
project?: EditorProjectSnapshot | null;
|
||||
asset?: EditorAssetSnapshot | null;
|
||||
queueState?: ExternalGenerationJobStatusRecord | null;
|
||||
|
||||
Reference in New Issue
Block a user