(null);
const [menuPosition, setMenuPosition] = useState<{
@@ -204,6 +206,12 @@ export function ImageCanvasAssetRowView({
) : (
+ onDownloadAsset(asset)}
+ />
{
expect(screen.getByAltText('画布图片:主视觉素材')).toBeTruthy();
});
+ it('downloads an asset directly from the asset library', async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = vi.fn(async (url: string) => {
+ if (url === '/creation-type-references/puzzle.webp') {
+ return new Response(new Blob(['asset'], { type: 'image/webp' }));
+ }
+ return new Response(null, { status: 404 });
+ }) as typeof fetch;
+ let downloadName = '';
+ let downloadHref = '';
+ Object.defineProperty(URL, 'createObjectURL', {
+ configurable: true,
+ value: vi.fn(() => 'blob:asset-library-download'),
+ });
+ Object.defineProperty(URL, 'revokeObjectURL', {
+ configurable: true,
+ value: vi.fn(),
+ });
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
+ function click(this: HTMLAnchorElement) {
+ downloadName = this.download;
+ downloadHref = this.href;
+ },
+ );
+
+ try {
+ render();
+
+ fireEvent.click(
+ await screen.findByRole('button', { name: '下载素材拼图素材' }),
+ );
+
+ await waitFor(() => {
+ expect(downloadName).toBe('拼图素材.webp');
+ });
+ expect(downloadHref).toBe('blob:asset-library-download');
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
it('collapses folders, creates upload folders, and deletes uploaded materials', async () => {
const user = userEvent.setup();
const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image');
diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx
index ed8e85a31..7bfba9f4b 100644
--- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx
+++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx
@@ -80,6 +80,7 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
setAssetPublicShowcaseEnabled: vi.fn(),
toggleAssetSelected: vi.fn(),
addAssetLayer: vi.fn(),
+ onDownloadAsset: vi.fn(),
toggleAllAssetsSelected: vi.fn(),
deleteSelectedAssets: vi.fn(),
closeAssetSelectionMode: vi.fn(),
@@ -135,6 +136,7 @@ function createStageProps(): ImageCanvasStageViewProps {
hoveredLayerId: null,
canvasMarquee: null,
canvasGenerationDialogs: [],
+ localTaskItems: [],
isTaskSidebarOpen: false,
generateDialog: null,
cropExpandPanel: null,
@@ -178,6 +180,7 @@ function createStageProps(): ImageCanvasStageViewProps {
onGenerationFramePointerDown: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onFocusExternalTask: vi.fn(),
+ onFocusLocalTask: vi.fn(),
onToggleTaskSidebar: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
onOpenQuickEditPanel: vi.fn(),
diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx
index 79ff38bd5..c478f11a2 100644
--- a/src/components/image-editor/ImageCanvasEditorView.tsx
+++ b/src/components/image-editor/ImageCanvasEditorView.tsx
@@ -42,9 +42,11 @@ import type {
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
+ CanvasTaskListItem,
CanvasTool,
CanvasViewport,
CharacterReferenceImage,
+ EditorAsset,
ImageContextMenuState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
@@ -167,6 +169,38 @@ async function readSystemClipboardImageFiles() {
return imageFiles;
}
+function createAssetActionLayer(asset: EditorAsset): CanvasLayer {
+ return {
+ id: `asset-download-${asset.id}`,
+ resourceId:
+ asset.sourceResourceId?.trim() || asset.assetObjectId?.trim() || asset.id,
+ title: asset.label,
+ src: asset.src,
+ mediaType: asset.mediaType,
+ thumbnailSrc: asset.thumbnailSrc,
+ x: 0,
+ y: 0,
+ width: asset.width,
+ height: asset.height,
+ originalWidth: asset.width,
+ originalHeight: asset.height,
+ zIndex: 0,
+ sourceType: asset.sourceType,
+ prompt: asset.prompt ?? null,
+ actualPrompt: asset.actualPrompt ?? null,
+ model: asset.model ?? null,
+ provider: asset.provider ?? null,
+ taskId: asset.taskId ?? null,
+ objectKey: asset.objectKey ?? null,
+ assetObjectId: asset.assetObjectId ?? null,
+ sourceResourceId: asset.sourceResourceId ?? null,
+ sourceAssetId: asset.id,
+ assetKind: asset.assetKind ?? null,
+ generationInputs: asset.generationInputs ?? null,
+ durationSeconds: asset.durationSeconds,
+ };
+}
+
export function ImageCanvasEditorView() {
const authUi = useAuthUi();
const [, setGenerationPricingVersion] = useState(0);
@@ -691,6 +725,14 @@ export function ImageCanvasEditorView() {
},
[focusCanvasGenerationDialog, focusCanvasLayerById],
);
+ const focusLocalCanvasTask = useCallback(
+ (task: CanvasTaskListItem) => {
+ if (task.focusLayerId) {
+ focusCanvasLayerById(task.focusLayerId);
+ }
+ },
+ [focusCanvasLayerById],
+ );
const persistGeneratedAsset = useCallback(
(layer: CanvasLayer) => {
if (authUiRef.current && !authUiRef.current.canAccessProtectedData) {
@@ -928,6 +970,12 @@ export function ImageCanvasEditorView() {
projectId,
projectTitle,
});
+ const downloadAsset = useCallback(
+ (asset: EditorAsset) => {
+ exportLayerImage(createAssetActionLayer(asset));
+ },
+ [exportLayerImage],
+ );
const {
uploadInputRef,
uploadTarget,
@@ -1631,6 +1679,7 @@ export function ImageCanvasEditorView() {
setAssetPublicShowcaseEnabled,
toggleAssetSelected,
addAssetLayer,
+ onDownloadAsset: downloadAsset,
toggleAllAssetsSelected,
deleteSelectedAssets,
closeAssetSelectionMode,
@@ -1686,6 +1735,7 @@ export function ImageCanvasEditorView() {
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
+ localTaskItems: generationSurface.backgroundRemovalTasks,
isTaskSidebarOpen: generationSurface.isTaskSidebarOpen,
generateDialog,
cropExpandPanel: generationSurface.cropExpandPanel,
@@ -1734,6 +1784,7 @@ export function ImageCanvasEditorView() {
onGenerationFramePointerDown: handleGenerationFramePointerDown,
onActivateGenerationDialog: activateCanvasGenerationDialog,
onFocusExternalTask: focusExternalGenerationTask,
+ onFocusLocalTask: focusLocalCanvasTask,
onToggleTaskSidebar: generationSurface.toggleTaskSidebar,
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
onOpenQuickEditPanel: openLayerGenerationDialog,
diff --git a/src/components/image-editor/ImageCanvasSidebarView.test.tsx b/src/components/image-editor/ImageCanvasSidebarView.test.tsx
index 0c348da35..88deb5d3c 100644
--- a/src/components/image-editor/ImageCanvasSidebarView.test.tsx
+++ b/src/components/image-editor/ImageCanvasSidebarView.test.tsx
@@ -118,6 +118,7 @@ function createSidebarProps(
setAssetPublicShowcaseEnabled: vi.fn(),
toggleAssetSelected: vi.fn(),
addAssetLayer: vi.fn(),
+ onDownloadAsset: vi.fn(),
toggleAllAssetsSelected: vi.fn(),
deleteSelectedAssets: vi.fn(),
closeAssetSelectionMode: vi.fn(),
@@ -189,6 +190,9 @@ describe('ImageCanvasSidebarView', () => {
expect(
within(folder).getByRole('button', { name: '上传到项目素材' }),
).toBeTruthy();
+ expect(
+ within(folder).getByRole('button', { name: '下载素材账号素材A' }),
+ ).toBeTruthy();
});
it('renders asset editing inputs with sidebar text-field chrome', () => {
diff --git a/src/components/image-editor/ImageCanvasSidebarView.tsx b/src/components/image-editor/ImageCanvasSidebarView.tsx
index 89054f40a..d984120af 100644
--- a/src/components/image-editor/ImageCanvasSidebarView.tsx
+++ b/src/components/image-editor/ImageCanvasSidebarView.tsx
@@ -102,6 +102,7 @@ export type ImageCanvasSidebarViewProps = {
) => void;
toggleAssetSelected: (assetId: string) => void;
addAssetLayer: (asset: EditorAsset) => void;
+ onDownloadAsset: (asset: EditorAsset) => void;
toggleAllAssetsSelected: () => void;
deleteSelectedAssets: () => void;
closeAssetSelectionMode: () => void;
@@ -167,6 +168,7 @@ export function ImageCanvasSidebarView({
setAssetPublicShowcaseEnabled,
toggleAssetSelected,
addAssetLayer,
+ onDownloadAsset,
toggleAllAssetsSelected,
deleteSelectedAssets,
closeAssetSelectionMode,
@@ -321,6 +323,7 @@ export function ImageCanvasSidebarView({
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
+ onDownloadAsset={onDownloadAsset}
toggleAllAssetsSelected={toggleAllAssetsSelected}
deleteSelectedAssets={deleteSelectedAssets}
closeAssetSelectionMode={closeAssetSelectionMode}
diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx
index ebe47c183..ed340d0d8 100644
--- a/src/components/image-editor/ImageCanvasStageView.tsx
+++ b/src/components/image-editor/ImageCanvasStageView.tsx
@@ -17,6 +17,7 @@ import type {
CanvasClipboard,
CanvasContextMenuState,
CanvasGenerationDialogState,
+ CanvasTaskListItem,
CanvasLayer,
CanvasMarqueeState,
CanvasTool,
@@ -57,6 +58,7 @@ export type ImageCanvasStageViewProps = {
hoveredLayerId: string | null;
canvasMarquee: CanvasMarqueeState | null;
canvasGenerationDialogs: CanvasGenerationDialogState[];
+ localTaskItems: CanvasTaskListItem[];
isTaskSidebarOpen: boolean;
generateDialog: GenerateDialogState | null;
cropExpandPanel: CropExpandPanelState | null;
@@ -116,6 +118,7 @@ export type ImageCanvasStageViewProps = {
) => void;
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
+ onFocusLocalTask: (task: CanvasTaskListItem) => void;
onToggleTaskSidebar: () => void;
onCropExpandHandlePointerDown: (
event: ReactPointerEvent,
@@ -195,6 +198,7 @@ export function ImageCanvasStageView({
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
+ localTaskItems,
isTaskSidebarOpen,
generateDialog,
cropExpandPanel,
@@ -239,6 +243,7 @@ export function ImageCanvasStageView({
onGenerationFramePointerDown,
onActivateGenerationDialog,
onFocusExternalTask,
+ onFocusLocalTask,
onToggleTaskSidebar,
onCropExpandHandlePointerDown,
onOpenQuickEditPanel,
@@ -438,9 +443,11 @@ export function ImageCanvasStageView({
{
expect(screen.getByText('暂无任务')).toBeTruthy();
expect(screen.queryByText(/已完成提示词/u)).toBeNull();
});
+
+ it('shows local background-removal tasks in the task list immediately', async () => {
+ const focusLocalTask = vi.fn();
+ const startedAt = Date.now() - 4000;
+ const localTask = {
+ id: 'background-removal-1',
+ title: '去背景:源图',
+ detail: '正在抠图',
+ status: 'running' as const,
+ startedAt,
+ focusLayerId: 'layer-source',
+ };
+
+ render(
+ ,
+ );
+
+ expect(await screen.findByText('去背景:源图')).toBeTruthy();
+ expect(screen.getByText(/正在抠图 · 已用时/u)).toBeTruthy();
+
+ fireEvent.click(screen.getByText('去背景:源图'));
+ expect(focusLocalTask).toHaveBeenCalledWith(localTask);
+ });
});
diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx
index 5fe705dfc..bd97ee3ef 100644
--- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx
+++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx
@@ -17,7 +17,10 @@ import {
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { listExternalGenerationTasks } from '../../services/external-generation';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
-import type { CanvasTaskStatus } from './ImageCanvasEditorTypes';
+import type {
+ CanvasTaskListItem,
+ CanvasTaskStatus,
+} from './ImageCanvasEditorTypes';
const COMPLETED_TASK_LIST_LIMIT = 20;
const ACTIVE_TASK_LIST_LIMIT = 100;
@@ -40,9 +43,11 @@ type TaskSidebarItem = {
type ImageCanvasTaskSidebarViewProps = {
projectId?: string | null;
+ localTasks?: CanvasTaskListItem[];
open: boolean;
onToggleOpen: () => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
+ onFocusLocalTask?: (task: CanvasTaskListItem) => void;
};
function parseTaskTimeMs(value?: string | null) {
@@ -212,9 +217,11 @@ function mergeExternalTaskRefresh(
export function ImageCanvasTaskSidebarView({
projectId,
+ localTasks = [],
open,
onToggleOpen,
onFocusExternalTask,
+ onFocusLocalTask,
}: ImageCanvasTaskSidebarViewProps) {
const [activeTab, setActiveTab] = useState('active');
const [now, setNow] = useState(() => Date.now());
@@ -342,9 +349,9 @@ export function ImageCanvasTaskSidebarView({
]);
useEffect(() => {
- const hasRunningTask = externalTasks.some(
- (task) => task.status === 'running',
- );
+ const hasRunningTask =
+ externalTasks.some((task) => task.status === 'running') ||
+ localTasks.some((task) => task.status === 'running');
if (!hasRunningTask) {
return undefined;
}
@@ -352,7 +359,7 @@ export function ImageCanvasTaskSidebarView({
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timerId);
- }, [externalTasks]);
+ }, [externalTasks, localTasks]);
const items = useMemo(() => {
const externalVisibleTasks = mergeExternalTasks(
@@ -401,8 +408,27 @@ export function ImageCanvasTaskSidebarView({
0,
};
});
- return externalItems;
- }, [externalTasks, now, onFocusExternalTask]);
+ const localItems = localTasks.map((task) => ({
+ id: `local-${task.id}`,
+ title: task.title,
+ detail: task.detail,
+ status: task.status,
+ startedAt: task.startedAt,
+ finishedAt: task.finishedAt,
+ elapsedMs:
+ task.status === 'running'
+ ? now - task.startedAt
+ : task.elapsedMs ??
+ (task.finishedAt ? task.finishedAt - task.startedAt : undefined),
+ errorMessage: task.errorMessage,
+ onClick:
+ task.focusLayerId && onFocusLocalTask
+ ? () => onFocusLocalTask(task)
+ : undefined,
+ sortTime: task.finishedAt ?? task.startedAt,
+ }));
+ return [...localItems, ...externalItems];
+ }, [externalTasks, localTasks, now, onFocusExternalTask, onFocusLocalTask]);
const activeItems = useMemo(
() =>
diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx
index e9bf7043c..717210232 100644
--- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx
+++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx
@@ -9,8 +9,20 @@ import type {
CanvasGenerationDialogState,
CanvasLayer,
} from './ImageCanvasEditorTypes';
+import { readLayerImageBlob } from './ImageCanvasExportModel';
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
+vi.mock('./ImageCanvasExportModel', () => ({
+ readLayerImageBlob: vi.fn(async (layer: CanvasLayer) => {
+ const blob = new Blob([layer.id], { type: 'image/png' });
+ Object.defineProperty(blob, 'layerId', {
+ configurable: true,
+ value: layer.id,
+ });
+ return blob;
+ }),
+}));
+
function createLayer(id: string, x: number, zIndex: number): CanvasLayer {
return {
id,
@@ -30,6 +42,7 @@ function createLayer(id: string, x: number, zIndex: number): CanvasLayer {
const originalClipboard = navigator.clipboard;
const originalClipboardItem = globalThis.ClipboardItem;
+const originalCreateImageBitmap = globalThis.createImageBitmap;
function installImageClipboardMock() {
class ClipboardItemMock {
@@ -283,10 +296,14 @@ afterEach(() => {
configurable: true,
value: originalClipboardItem,
});
+ Object.defineProperty(globalThis, 'createImageBitmap', {
+ configurable: true,
+ value: originalCreateImageBitmap,
+ });
});
describe('useImageCanvasLayerCommands', () => {
- it('writes the first copied image layer to the system clipboard', async () => {
+ it('writes the last copied image layer to the system clipboard', async () => {
const write = installImageClipboardMock();
render();
@@ -297,9 +314,49 @@ describe('useImageCanvasLayerCommands', () => {
| Array<{ items: Record }>
| undefined;
expect(clipboardItems?.[0]?.items['image/png']).toBeInstanceOf(Blob);
+ expect(readLayerImageBlob).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'second' }),
+ );
expect(screen.getByTestId('clipboard').textContent).toBe('copy:2');
});
+ it('normalizes copied images to png for the system clipboard', async () => {
+ const write = installImageClipboardMock();
+ const drawImage = vi.fn();
+ const toBlob = vi.fn((resolve: (blob: Blob) => void) =>
+ resolve(new Blob(['png'], { type: 'image/png' })),
+ );
+ vi.mocked(readLayerImageBlob).mockResolvedValueOnce(
+ new Blob(['webp'], { type: 'image/webp' }),
+ );
+ Object.defineProperty(globalThis, 'createImageBitmap', {
+ configurable: true,
+ value: vi.fn(async () => ({ width: 16, height: 12 })),
+ });
+ const originalCreateElement = document.createElement.bind(document);
+ vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
+ if (tagName === 'canvas') {
+ return {
+ width: 0,
+ height: 0,
+ getContext: () => ({ drawImage }),
+ toBlob,
+ } as unknown as HTMLCanvasElement;
+ }
+ return originalCreateElement(tagName);
+ });
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: '复制选中' }));
+
+ await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
+ const clipboardItems = write.mock.calls[0]?.[0] as
+ | Array<{ items: Record }>
+ | undefined;
+ expect(clipboardItems?.[0]?.items['image/png']?.type).toBe('image/png');
+ expect(drawImage).toHaveBeenCalledTimes(1);
+ });
+
it('keeps canvas copy when system image clipboard is unavailable', () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -331,13 +388,21 @@ describe('useImageCanvasLayerCommands', () => {
fireEvent.click(screen.getByRole('button', { name: '复制选中' }));
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
+ expect(readLayerImageBlob).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'second' }),
+ );
expect(screen.getByTestId('clipboard').textContent).toBe('copy:2');
});
- it('copies, cuts, and pastes context layers with history and selection updates', () => {
+ it('copies, cuts, and pastes context layers with history and selection updates', async () => {
+ const write = installImageClipboardMock();
render();
fireEvent.click(screen.getByRole('button', { name: '复制' }));
+ await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
+ expect(readLayerImageBlob).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'second' }),
+ );
expect(screen.getByTestId('clipboard').textContent).toBe('copy:2');
expect(screen.getByTestId('context').textContent).toBe('closed');
expect(screen.getByTestId('image-context-closed').textContent).toBe('1');
diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts
index 648c7a95e..bc0b4d414 100644
--- a/src/components/image-editor/useImageCanvasLayerCommands.ts
+++ b/src/components/image-editor/useImageCanvasLayerCommands.ts
@@ -72,7 +72,27 @@ function isClipboardImageLayer(layer: CanvasLayer) {
);
}
-function normalizeClipboardImageBlob(blob: Blob) {
+async function normalizeClipboardImageBlob(blob: Blob) {
+ if (blob.type === 'image/png') {
+ return blob;
+ }
+ if (typeof createImageBitmap === 'function') {
+ try {
+ const image = await createImageBitmap(blob);
+ const canvas = document.createElement('canvas');
+ canvas.width = image.width;
+ canvas.height = image.height;
+ canvas.getContext('2d')?.drawImage(image, 0, 0);
+ const pngBlob = await new Promise((resolve) =>
+ canvas.toBlob(resolve, 'image/png'),
+ );
+ if (pngBlob) {
+ return pngBlob;
+ }
+ } catch {
+ // 浏览器不支持当前图片解码时,回退到原始字节。
+ }
+ }
if (!blob.type || blob.type === 'application/octet-stream') {
return blob.slice(0, blob.size, 'image/png');
}
@@ -81,12 +101,14 @@ function normalizeClipboardImageBlob(blob: Blob) {
function copyLayerImageToSystemClipboard(layers: CanvasLayer[]) {
void (async () => {
- const layer = layers.find(isClipboardImageLayer);
+ const layer = layers.slice().reverse().find(isClipboardImageLayer);
const clipboard = globalThis.navigator?.clipboard;
if (!layer || !clipboard?.write || typeof ClipboardItem !== 'function') {
return;
}
- const blob = normalizeClipboardImageBlob(await readLayerImageBlob(layer));
+ const blob = await normalizeClipboardImageBlob(
+ await readLayerImageBlob(layer),
+ );
await clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
})().catch(() => {
// 系统剪贴板只是画布内复制的附加能力,失败不阻断内部粘贴。