修复画板私有素材预览
让生成参考图和角色动作参考图通过 objectKey 换签显示 让视频图层 poster 与框选预览复用签名读取 补充参考槽、框选预览和视频图层回归测试 沉淀框选预览换签缓存踩坑
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user