合并编辑器素材修复分支

合并 codex/240e-editor-asset-fixes 到 codex/editor-asset-library

保留视频生成资源与素材返回

保留视频缩略图与生成输入元数据
This commit is contained in:
2026-06-29 11:36:49 +08:00
24 changed files with 748 additions and 80 deletions
@@ -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;
+9
View File
@@ -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;