diff --git a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md
index 1c6cbb25b..1e2ae5e4f 100644
--- a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md
+++ b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md
@@ -30,6 +30,8 @@
左侧素材库选择模式同时支持导出当前可见范围内的选中素材:只选中一个普通素材时直接下载原文件;选中多个素材时沿用画布素材 ZIP 的读取、去重、媒体分目录、序列帧、元数据、部分失败和浏览器下载能力,生成 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,包内根目录为 `项目名-选中素材/`。单素材、单序列帧、选中素材 ZIP 和画布素材 ZIP 的下载文件名都必须包含到秒的本地时间戳,避免同一天重复导出时重名。该入口不改变中央画布和图层列表的选择 / 下载语义。
+持久素材只要 `src` 或 `objectKey` 任一有效即可进入点击、Shift、框选和全选范围。`src` 为空但保留私有 `objectKey` 的素材,导出时复用统一资源读取链路换签;浏览器无法直读签名 URL 时继续回退同源 `/api/assets/read-bytes` 字节代理,不得因缺少临时展示地址而过滤。
+
暂不实现:
- 画布整体截图 PNG。
diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts
index 18b49d772..323f05972 100644
--- a/src/components/image-editor/ImageCanvasExportModel.test.ts
+++ b/src/components/image-editor/ImageCanvasExportModel.test.ts
@@ -282,7 +282,7 @@ describe('ImageCanvasExportModel', () => {
try {
const blob = await readLayerAssetBlob(
buildLayer({
- src: '/generated-editor-videos/video.mp4',
+ src: '',
mediaType: 'video',
objectKey: 'generated/video.mp4',
}),
diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx
index fb2e52973..d411b4901 100644
--- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx
+++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx
@@ -175,6 +175,89 @@ describe('useImageCanvasAssetExportWorkflow', () => {
}
});
+ it('exports selected object-key-only assets through the private byte reader', async () => {
+ const originalFetch = globalThis.fetch;
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.startsWith('/api/assets/read-url?')) {
+ return new Response(null, { status: 404 });
+ }
+ if (url.startsWith('/api/assets/read-bytes?')) {
+ const objectKey = new URL(url, 'http://localhost').searchParams.get(
+ 'objectKey',
+ );
+ return new Response(new Blob([objectKey ?? ''], { type: 'image/png' }));
+ }
+ return new Response(null, { status: 404 });
+ });
+ globalThis.fetch = fetchMock as typeof fetch;
+ let exportedBlob: Blob | null = null;
+ Object.defineProperty(URL, 'createObjectURL', {
+ configurable: true,
+ value: vi.fn((blob: Blob) => {
+ exportedBlob = blob;
+ return 'blob:object-key-only-export';
+ }),
+ });
+ Object.defineProperty(URL, 'revokeObjectURL', {
+ configurable: true,
+ value: vi.fn(),
+ });
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
+
+ try {
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: '下载多个选中素材' }));
+ await waitFor(() => expect(exportedBlob).toBeTruthy());
+
+ const byteReadUrls = fetchMock.mock.calls
+ .map(([input]) => String(input))
+ .filter((url) => url.startsWith('/api/assets/read-bytes?'));
+ expect(byteReadUrls).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining(
+ 'objectKey=generated%2Feditor%2Fprivate-a.png',
+ ),
+ expect.stringContaining(
+ 'objectKey=generated%2Feditor%2Fprivate-b.png',
+ ),
+ ]),
+ );
+ const zip = await JSZip.loadAsync(exportedBlob!);
+ expect(
+ zip.file('导出项目-选中素材/images/001-私有素材A.png'),
+ ).toBeTruthy();
+ expect(
+ zip.file('导出项目-选中素材/images/002-私有素材B.png'),
+ ).toBeTruthy();
+ expect(screen.getByTestId('status').textContent).toBe(
+ 'success:选中素材已导出',
+ );
+ } finally {
+ globalThis.fetch = originalFetch;
+ delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
+ delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
+ }
+ });
+
it('prevents concurrent collection exports before React re-renders', async () => {
const originalFetch = globalThis.fetch;
let resolveFetch: (response: Response) => void = () => {};
diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts
index 88d843f12..092cc1e4a 100644
--- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts
+++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts
@@ -1,6 +1,7 @@
import JSZip from 'jszip';
import { useCallback, useRef, useState } from 'react';
+import { hasReadableAssetSource } from '../../services/assetReadUrlService';
import type {
CanvasAssetExportImage,
CanvasAssetExportMetadata,
@@ -418,7 +419,9 @@ export function useImageCanvasAssetExportWorkflow({
return;
}
const exportableLayers = targetLayers
- .filter((layer) => layer.src.trim().length > 0)
+ .filter((layer) =>
+ hasReadableAssetSource(layer.src, layer.objectKey),
+ )
.sort((left, right) => left.zIndex - right.zIndex);
if (!exportableLayers.length) {
setAssetExportStatus({
@@ -759,7 +762,7 @@ export function useImageCanvasAssetExportWorkflow({
return;
}
const exportableLayers = selectedLayers.filter(
- (layer) => layer.src.trim().length > 0,
+ (layer) => hasReadableAssetSource(layer.src, layer.objectKey),
);
if (!exportableLayers.length) {
setAssetExportStatus({
diff --git a/src/components/image-editor/useImageCanvasAssetSelection.test.tsx b/src/components/image-editor/useImageCanvasAssetSelection.test.tsx
index 3922e92cf..8e4c9eba5 100644
--- a/src/components/image-editor/useImageCanvasAssetSelection.test.tsx
+++ b/src/components/image-editor/useImageCanvasAssetSelection.test.tsx
@@ -46,9 +46,14 @@ describe('useImageCanvasAssetSelection', () => {
createAsset({ id: 'uploading', uploadStatus: 'uploading' }),
createAsset({ id: 'failed', uploadStatus: 'failed' }),
createAsset({ id: 'empty', src: ' ' }),
+ createAsset({
+ id: 'object-key-only',
+ src: '',
+ objectKey: 'generated/editor/object-key-only.png',
+ }),
createAsset({ id: 'built-in', sourceKind: 'built-in' }),
]).map((asset) => asset.id),
- ).toEqual(['ready']);
+ ).toEqual(['ready', 'object-key-only']);
});
it('owns marquee geometry and selectable hit testing', () => {
@@ -80,6 +85,11 @@ describe('useImageCanvasAssetSelection', () => {
assets: [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
+ createAsset({
+ id: 'object-key-only',
+ src: '',
+ objectKey: 'generated/editor/object-key-only.png',
+ }),
createAsset({ id: 'built-in', sourceKind: 'built-in' }),
],
assetTargets: [
@@ -95,10 +105,58 @@ describe('useImageCanvasAssetSelection', () => {
assetId: 'built-in',
rect: { left: 15, right: 25, top: 15, bottom: 25 },
},
+ {
+ assetId: 'object-key-only',
+ rect: { left: 25, right: 30, top: 25, bottom: 30 },
+ },
],
selectionRect: { left: 20, right: 35, top: 20, bottom: 35 },
}),
- ]).toEqual(['asset-a', 'asset-b']);
+ ]).toEqual(['asset-a', 'asset-b', 'object-key-only']);
+ });
+
+ it('supports click, shift and select-all for object-key-only assets', () => {
+ const assets = [
+ createAsset({
+ id: 'object-key-a',
+ src: '',
+ objectKey: 'generated/editor/object-key-a.png',
+ }),
+ createAsset({
+ id: 'object-key-b',
+ src: '',
+ objectKey: 'generated/editor/object-key-b.png',
+ }),
+ ];
+ const visibleAssetIds = getSelectableAssets(assets).map(
+ (asset) => asset.id,
+ );
+ const { result } = renderHook(() =>
+ useImageCanvasAssetSelection({
+ assetListRef: createRef(),
+ assets,
+ }),
+ );
+
+ act(() => result.current.openAssetSelectionMode());
+ act(() =>
+ result.current.toggleAssetSelected('object-key-a', { visibleAssetIds }),
+ );
+ act(() =>
+ result.current.toggleAssetSelected('object-key-b', {
+ visibleAssetIds,
+ shiftKey: true,
+ }),
+ );
+ expect([...result.current.selectedAssetIds]).toEqual([
+ 'object-key-a',
+ 'object-key-b',
+ ]);
+
+ act(() => result.current.toggleAllAssetsSelected(visibleAssetIds));
+ expect(result.current.selectedAssetIds.size).toBe(0);
+ act(() => result.current.toggleAllAssetsSelected(visibleAssetIds));
+ expect([...result.current.selectedAssetIds]).toEqual(visibleAssetIds);
});
it('applies tick, shift and visible select-all as deltas', () => {
diff --git a/src/components/image-editor/useImageCanvasAssetSelection.ts b/src/components/image-editor/useImageCanvasAssetSelection.ts
index 219b7f5e8..c94a0a45b 100644
--- a/src/components/image-editor/useImageCanvasAssetSelection.ts
+++ b/src/components/image-editor/useImageCanvasAssetSelection.ts
@@ -1,6 +1,7 @@
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { hasReadableAssetSource } from '../../services/assetReadUrlService';
import type { AssetMarqueeState, EditorAsset } from './ImageCanvasEditorTypes';
const ASSET_ITEM_SELECTOR = '[data-asset-id]';
@@ -40,7 +41,7 @@ export function getSelectableAssets(assets: EditorAsset[]) {
asset.sourceKind === 'uploaded' &&
asset.uploadStatus !== 'uploading' &&
asset.uploadStatus !== 'failed' &&
- asset.src.trim().length > 0,
+ hasReadableAssetSource(asset.src, asset.objectKey),
);
}
diff --git a/src/services/assetReadUrlService.ts b/src/services/assetReadUrlService.ts
index 85feb9380..671384fd5 100644
--- a/src/services/assetReadUrlService.ts
+++ b/src/services/assetReadUrlService.ts
@@ -102,6 +102,15 @@ export function shouldResolveAssetReadUrl(source: string | null | undefined) {
);
}
+export function hasReadableAssetSource(
+ source: string | null | undefined,
+ objectKey?: string | null,
+) {
+ return Boolean(
+ source?.trim() || objectKey?.trim().replace(/^\/+/u, ''),
+ );
+}
+
function normalizeLegacyPublicPath(value: string) {
return `/${value.trim().replace(/^\/+/u, '')}`;
}
@@ -531,11 +540,11 @@ export async function readAssetBytes(
options: AssetReadBytesOptions = {},
) {
const value = source?.trim() ?? '';
- if (!value) {
+ const objectKey = options.objectKey?.trim().replace(/^\/+/u, '') ?? '';
+ if (!hasReadableAssetSource(value, objectKey)) {
throw new Error('资源路径不能为空');
}
- const objectKey = options.objectKey?.trim().replace(/^\/+/u, '') ?? '';
const legacyPath = isGeneratedLegacyPath(value)
? value
: resolveGeneratedLegacyPathFromUrl(value);