修复画板私有素材预览

让生成参考图和角色动作参考图通过 objectKey 换签显示

让视频图层 poster 与框选预览复用签名读取

补充参考槽、框选预览和视频图层回归测试

沉淀框选预览换签缓存踩坑
This commit is contained in:
2026-06-23 23:23:16 +08:00
parent f1b4e70bda
commit 5a884f1d11
15 changed files with 226 additions and 21 deletions
@@ -151,6 +151,14 @@
- 验证:Network 中 `/api/editor/projects*``PATCH /api/editor/projects/{id}`、素材库接口不应出现 `data:image` / `data:video` / `data:audio`;素材库和图层面板缩略图都能换签显示;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/services/assetReadUrlService.test.ts src/services/image-editor/editorProjectClient.test.ts`,后端跑 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/editor_project.rs``src/components/image-editor/ImageCanvasEditorModel.ts``src/components/image-editor/useImageCanvasProjectPersistence.ts``src/components/common/PlatformMediaFrame.tsx``src/services/assetReadUrlService.ts`
## 图片画布框选预览要复用源图换签缓存
- 现象:UI 设计素材提取或快速编辑框选时,画布上的红色框选还在,但底部“框选区域预览”卡片变成空白。
- 原因:预览图从原生 `img` 改成 `ResolvedAssetImage` 后,如果没有传入源图同一套 `objectKey` / `refreshKey`,它会另起一条 `/api/assets/read-url` 缓存维度;画布主图已经显示时,预览仍可能处于空签名或失败缓存状态。
- 处理:框选预览继续用 `ResolvedAssetImage` 承接私有资源换签,但必须传源图 `objectKey`,并使用 `taskId ?? resourceId` 作为 `refreshKey`,和主画布图片保持同一签名缓存版本。只允许对 `data:``blob:` 或已带签名参数的 URL 设置 `fallbackSrc`;不要把裸 `/generated...` 私有路径作为 fallback 写进 `img`
- 验证:`npm run test -- src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx --reporter=dot` 应断言私有框选预览带 `objectKey``refreshKey`,且裸 generated 路径没有 fallback。
- 关联:`src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx``src/components/ResolvedAssetImage.tsx``src/hooks/useResolvedAssetReadUrl.ts``src/services/assetReadUrlService.ts`
## 图片画布发布入口 429 先查自动保存 PATCH 并发
- 现象:发布域名访问画板时出现短时间密集 `429`Nginx access log 中 `PATCH /api/editor/projects/<projectId>`、生成接口和资料接口混杂,429 行常见 `request_time=0.000``upstream_status=-`error log 写 `limiting connections by zone "genarrative_api_conn"`
@@ -65,6 +65,7 @@ function ReferenceChip({
tone="default"
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
imageSrc={reference.src}
objectKey={reference.objectKey}
label={label}
ariaLabel={label}
title={reference.label}
@@ -1,4 +1,4 @@
import { ChevronDown, X } from 'lucide-react';
import { ChevronDown, ImageIcon, X } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
@@ -29,6 +29,7 @@ import {
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_RATIO_OPTIONS,
} from './ImageCanvasGenerationModel';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
type ImageCanvasCharacterAnimationPanelViewProps = {
@@ -177,17 +178,15 @@ export function ImageCanvasCharacterAnimationPanelView({
}}
>
<div className="image-canvas-editor__reference-strip">
<span
className="image-canvas-editor__reference-chip image-canvas-editor__reference-chip--character"
<ImageCanvasReferenceSlot
tone="character"
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
imageSrc={sourceLayer.src}
objectKey={sourceLayer.objectKey}
label={sourceLayer.title}
ariaLabel={sourceLayer.title}
title={sourceLayer.title}
>
<span className="image-canvas-editor__reference-chip-icon">
<img src={sourceLayer.src} alt="" aria-hidden="true" />
</span>
<span className="image-canvas-editor__reference-chip-label">
{sourceLayer.title}
</span>
</span>
/>
</div>
<EditorIconButton
className="image-canvas-editor__generation-close"
@@ -70,6 +70,7 @@ function ReferenceChip({
tone="character"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
imageSrc={reference.src}
objectKey={reference.objectKey}
label={label}
ariaLabel={label}
title={reference.label}
@@ -127,6 +128,7 @@ export function ImageCanvasCharacterGenerationComposerView({
tone="spec"
icon={<ClipboardList className="h-4 w-4" aria-hidden="true" />}
imageSrc={dialog.characterSpecReference?.src}
objectKey={dialog.characterSpecReference?.objectKey}
label="角色规范"
ariaLabel={dialog.characterSpecReference?.label ?? '角色规范'}
title={dialog.characterSpecReference?.label ?? '角色规范'}
@@ -186,7 +186,13 @@ function VideoReferenceChip({
index,
onRemove,
}: {
reference: { id: string; label: string; src: string; mediaType?: string };
reference: {
id: string;
label: string;
src: string;
mediaType?: string;
objectKey?: string | null;
};
index: number;
onRemove: () => void;
}) {
@@ -211,6 +217,7 @@ function VideoReferenceChip({
tone={mediaType === 'audio' ? 'audio' : 'video'}
icon={icon}
imageSrc={mediaType === 'image' ? reference.src : undefined}
objectKey={mediaType === 'image' ? reference.objectKey : undefined}
label={label}
ariaLabel={label}
title={reference.label}
@@ -107,6 +107,7 @@ export function ImageCanvasIconSpritesheetComposerView({
className="image-canvas-editor__icon-spec-card"
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
imageSrc={dialog.iconSpecReference?.src}
objectKey={dialog.iconSpecReference?.objectKey}
label="图标规范"
ariaLabel={dialog.iconSpecReference?.label ?? '图标规范'}
title={dialog.iconSpecReference?.label ?? '图标规范'}
@@ -92,6 +92,7 @@ function ReferenceThumb({
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
imageSrc={reference.src}
objectKey={reference.objectKey}
label={label}
ariaLabel={label}
title={reference.label}
@@ -82,6 +82,7 @@ function QuickEditReferenceChip({
tone="quick-edit"
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
imageSrc={reference.src}
objectKey={reference.objectKey}
label={label}
ariaLabel={label}
title={reference.label}
@@ -0,0 +1,52 @@
/* @vitest-environment jsdom */
import { render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
const useResolvedAssetReadUrlMock = vi.hoisted(() => vi.fn());
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: useResolvedAssetReadUrlMock,
}));
describe('ImageCanvasReferenceSlot', () => {
beforeEach(() => {
useResolvedAssetReadUrlMock.mockImplementation(
(source: string | null | undefined, options?: { objectKey?: string | null }) => ({
resolvedUrl: options?.objectKey
? `https://oss.example.com/${options.objectKey}?signature=1`
: (source ?? ''),
isResolving: false,
shouldResolve: Boolean(options?.objectKey),
}),
);
});
it('resolves private generated reference thumbnails through objectKey', () => {
const { container } = render(
<ImageCanvasReferenceSlot
tone="character"
icon={<span data-testid="fallback-icon" />}
imageSrc="/generated-character-drafts/editor/legacy-resources/stale.png"
objectKey="generated-character-drafts/editor/legacy-resources/editor-resource-1.png"
label="角色参考"
ariaLabel="角色参考"
/>,
);
const image = container.querySelector('img');
expect(useResolvedAssetReadUrlMock).toHaveBeenCalledWith(
'/generated-character-drafts/editor/legacy-resources/stale.png',
expect.objectContaining({
objectKey:
'generated-character-drafts/editor/legacy-resources/editor-resource-1.png',
}),
);
expect(image?.getAttribute('src')).toBe(
'https://oss.example.com/generated-character-drafts/editor/legacy-resources/editor-resource-1.png?signature=1',
);
});
});
@@ -1,6 +1,8 @@
import { type ReactNode, type RefObject } from 'react';
import { X } from 'lucide-react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type ImageCanvasReferenceSlotTone =
| 'default'
| 'spec'
@@ -20,6 +22,7 @@ type ImageCanvasReferenceSlotProps = {
ariaLabel: string;
title?: string;
imageSrc?: string | null;
objectKey?: string | null;
disabled?: boolean;
isAdd?: boolean;
onClick?: () => void;
@@ -36,13 +39,14 @@ export function ImageCanvasReferenceSlot({
ariaLabel,
title,
imageSrc,
objectKey,
disabled = false,
isAdd = false,
onClick,
onRemove,
removeLabel,
}: ImageCanvasReferenceSlotProps) {
const hasPreviewImage = Boolean(imageSrc);
const hasPreviewImage = Boolean(imageSrc?.trim() || objectKey?.trim());
const slotClassName = [
'image-canvas-editor__reference-chip',
`image-canvas-editor__reference-chip--${tone}`,
@@ -58,7 +62,12 @@ export function ImageCanvasReferenceSlot({
<>
<span className="image-canvas-editor__reference-chip-icon">
{hasPreviewImage ? (
<img src={imageSrc ?? undefined} alt="" aria-hidden="true" />
<ResolvedAssetImage
src={imageSrc}
objectKey={objectKey}
alt=""
aria-hidden="true"
/>
) : (
icon
)}
@@ -140,6 +140,7 @@ export function ImageCanvasSpecGenerationPanelView({
)
}
imageSrc={reference?.src}
objectKey={reference?.objectKey}
label={referenceSlotLabel}
ariaLabel={referenceLabel}
title={reference?.label ?? referenceLabel}
@@ -1,12 +1,41 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import type { ImgHTMLAttributes } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel';
import { ImageCanvasUiAssetExtractionOverlayView } from './ImageCanvasUiAssetExtractionOverlayView';
type ResolvedAssetImageMockProps = Omit<
ImgHTMLAttributes<HTMLImageElement>,
'src'
> & {
src?: string | null;
objectKey?: string | null;
fallbackSrc?: string | null;
refreshKey?: string | number | null;
};
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
objectKey,
fallbackSrc,
refreshKey,
...props
}: ResolvedAssetImageMockProps) => (
<img
{...props}
src={src ?? fallbackSrc ?? ''}
data-object-key={objectKey ?? ''}
data-fallback-src={fallbackSrc ?? ''}
data-refresh-key={refreshKey == null ? '' : String(refreshKey)}
/>
),
}));
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-source',
@@ -168,6 +197,50 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
expect(previewImage.style.top).toBe('-200%');
});
it('reuses the source layer signed read-url cache for private crop previews', () => {
render(
<ImageCanvasUiAssetExtractionOverlayView
sourceLayer={createLayer({
src: '/generated-editor-assets/project-1/source.png',
objectKey: 'generated-editor-assets/project-1/source.png',
resourceId: 'resource-private',
taskId: 'task-private',
})}
viewport={{ x: 10, y: 20, scale: 1 }}
state={createState({
marks: [
{
id: 'mark-1',
tool: 'rect',
x: 160,
y: 120,
width: 80,
height: 60,
},
],
})}
onToolChange={vi.fn()}
onPointerStart={vi.fn()}
onPointerMove={vi.fn()}
onPointerEnd={vi.fn()}
onSubmit={vi.fn()}
/>,
);
const preview = screen.getByLabelText('框选区域预览 1');
const previewImage = within(preview).getByRole('img', {
name: '源图框选预览 1',
});
expect(previewImage.getAttribute('data-object-key')).toBe(
'generated-editor-assets/project-1/source.png',
);
expect(previewImage.getAttribute('data-refresh-key')).toBe(
'task-private',
);
expect(previewImage.getAttribute('data-fallback-src')).toBe('');
});
it('shows numbered labels for quick edit red selections', () => {
const { container } = render(
<ImageCanvasUiAssetExtractionOverlayView
@@ -4,6 +4,7 @@ import type { PointerEvent as ReactPointerEvent } from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
import {
calculateEditorIconSpritesheetPrice,
@@ -127,23 +128,48 @@ function resolveMarkPreviewBounds(
});
}
function renderMarkPreview(
mark: UiAssetExtractionMark,
index: number,
sourceLayer: CanvasLayer,
) {
function resolveMarkPreviewFallbackSrc(sourceSrc: string) {
const normalizedSrc = sourceSrc.trim();
if (!normalizedSrc) {
return undefined;
}
if (/^(?:data|blob):/iu.test(normalizedSrc)) {
return normalizedSrc;
}
if (
/^https?:\/\//iu.test(normalizedSrc) &&
/[?&](?:x-oss-signature|OSSAccessKeyId|Signature)=/iu.test(normalizedSrc)
) {
return normalizedSrc;
}
return undefined;
}
function UiAssetExtractionMarkPreview({
mark,
index,
sourceLayer,
}: {
mark: UiAssetExtractionMark;
index: number;
sourceLayer: CanvasLayer;
}) {
const bounds = resolveMarkPreviewBounds(mark, sourceLayer);
const originalWidth = Math.max(1, sourceLayer.originalWidth);
const originalHeight = Math.max(1, sourceLayer.originalHeight);
const previewRefreshKey = sourceLayer.taskId ?? sourceLayer.resourceId;
return (
<div
key={mark.id}
aria-label={`框选区域预览 ${index + 1}`}
className="image-canvas-editor__ui-extraction-preview"
>
<img
<ResolvedAssetImage
alt={`${sourceLayer.title}框选预览 ${index + 1}`}
src={sourceLayer.src}
objectKey={sourceLayer.objectKey}
fallbackSrc={resolveMarkPreviewFallbackSrc(sourceLayer.src)}
refreshKey={previewRefreshKey}
style={{
width: `${(originalWidth / bounds.width) * 100}%`,
height: `${(originalHeight / bounds.height) * 100}%`,
@@ -156,6 +182,21 @@ function renderMarkPreview(
);
}
function renderMarkPreview(
mark: UiAssetExtractionMark,
index: number,
sourceLayer: CanvasLayer,
) {
return (
<UiAssetExtractionMarkPreview
key={mark.id}
mark={mark}
index={index}
sourceLayer={sourceLayer}
/>
);
}
function renderMark(
mark: UiAssetExtractionMark,
index: number,
@@ -962,6 +962,8 @@ describe('ImageCanvasWorldView', () => {
resolvedUrl:
source === '/generated-character-drafts/editor-videos/video-7.mp4'
? 'https://oss.example.com/video-7.mp4?signature=1'
: source === '/generated-character-drafts/editor/video-7-poster.png'
? 'https://oss.example.com/video-7-poster.png?signature=1'
: source,
isResolving: false,
shouldResolve: true,
@@ -971,6 +973,7 @@ describe('ImageCanvasWorldView', () => {
src: '/generated-character-drafts/editor-videos/video-7.mp4',
mediaType: 'video',
assetKind: 'video',
thumbnailSrc: '/generated-character-drafts/editor/video-7-poster.png',
width: 854,
height: 480,
originalWidth: 854,
@@ -988,6 +991,9 @@ describe('ImageCanvasWorldView', () => {
expect(video.getAttribute('src')).toBe(
'https://oss.example.com/video-7.mp4?signature=1',
);
expect(video.getAttribute('poster')).toBe(
'https://oss.example.com/video-7-poster.png?signature=1',
);
expect(video.hasAttribute('controls')).toBe(true);
expect(video.hasAttribute('playsinline')).toBe(true);
expect(video.getAttribute('preload')).toBe('metadata');
@@ -583,6 +583,9 @@ function ImageCanvasVideoLayer({
objectKey: layer.objectKey,
refreshKey: layer.taskId ?? layer.resourceId,
});
const { resolvedUrl: posterUrl } = useResolvedAssetReadUrl(layer.thumbnailSrc, {
refreshKey: `${layer.taskId ?? layer.resourceId}:poster`,
});
const [showGeneratedPreview, setShowGeneratedPreview] = useState(
!layer.thumbnailSrc,
);
@@ -599,7 +602,7 @@ function ImageCanvasVideoLayer({
<video
className="image-canvas-editor__layer-video"
src={resolvedUrl || undefined}
poster={layer.thumbnailSrc ?? undefined}
poster={posterUrl || undefined}
controls
playsInline
preload="metadata"