合并:同步最新master
合并 origin/master 8f19964e3 到 BGM 优化分支
保留 BGM 记录、完美像素改动及双方前端测试
通过冲突相关前端定向测试(61项)
This commit is contained in:
@@ -40,6 +40,7 @@ const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
@@ -74,7 +75,10 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
|
||||
|
||||
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
uploadEditorMediaAssetObjectFile: vi.fn(),
|
||||
// 中文注释:inline 源图上传走的是 object-only 版本,不是带签名 URL 的那个。留成裸 vi.fn()
|
||||
// 会返回 undefined,取 objectKey 抛的 TypeError 被 extractUiDesignAssets 的 catch 吞成
|
||||
// window.alert,最终只表现为「提取接口一次都没调」,报错里看不到任何线索。
|
||||
uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock,
|
||||
}));
|
||||
|
||||
vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({
|
||||
@@ -113,6 +117,17 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||||
assetObjectId: 'asset-object-marked-ui-design',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||||
src: 'https://oss.example.com/marked-ui-design.png',
|
||||
});
|
||||
uploadEditorMediaAssetObjectFileMock.mockReset();
|
||||
uploadEditorMediaAssetObjectFileMock.mockResolvedValue({
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||||
assetObjectId: 'asset-object-marked-ui-design',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3387,7 +3402,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
'https://assets.test/generated-character-drafts/editor/frame1.png',
|
||||
);
|
||||
});
|
||||
expect(screen.getByText('1/48')).toBeTruthy();
|
||||
// 中文注释:分子不能钉死。序列帧图层默认自动播放(frames.length > 1 即 isPlaying),
|
||||
// 48 帧 6 秒算出 125ms 的真实 setInterval,而这里用的是真实定时器下的 waitFor——
|
||||
// 上一条 waitFor 在 CI 上多轮询一次就已经越过 125ms,计数器变成 2/48。要断言的是
|
||||
// 「按序列帧播放器渲染、总帧数 48」(下一行排除视频元素),分母才是这条断言的意义。
|
||||
expect(screen.getByText(/^\d+\/48$/u)).toBeTruthy();
|
||||
expect(screen.queryByLabelText('画布视频:角色动作')).toBeNull();
|
||||
expect(screen.getByText('动作')).toBeTruthy();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -216,6 +216,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
onOpenCropExpandPanel: vi.fn(),
|
||||
onRemoveBackground: vi.fn(),
|
||||
onPerfectPixel: vi.fn(),
|
||||
onSplitIconSpritesheet: vi.fn(),
|
||||
onExtractUiDesignAssets: vi.fn(),
|
||||
onUiAssetExtractionToolChange: vi.fn(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
EditorImageGenerationStyle,
|
||||
EditorPixelArtSnapInput,
|
||||
EditorVideoAspectRatio,
|
||||
EditorVideoModel,
|
||||
EditorVideoResolution,
|
||||
@@ -186,6 +187,16 @@ export type PublicationMaterialsWorkflowId =
|
||||
| 'publication-detail-gallery'
|
||||
| 'publication-promo-poster';
|
||||
|
||||
export type PerfectPixelOperationSnapshot = {
|
||||
version: 1;
|
||||
kind: 'perfect-pixel';
|
||||
operationId: string;
|
||||
taskId: string;
|
||||
request: EditorPixelArtSnapInput;
|
||||
submittedAt: number;
|
||||
reconcileUntil: number;
|
||||
};
|
||||
|
||||
export type GenerateDialogState = {
|
||||
id?: string;
|
||||
mode:
|
||||
@@ -203,7 +214,7 @@ export type GenerateDialogState = {
|
||||
| 'audio-background-music';
|
||||
prompt: string;
|
||||
assetLabel?: string;
|
||||
status: 'idle' | 'generating' | 'failed';
|
||||
status: 'idle' | 'generating' | 'pending-confirmation' | 'failed';
|
||||
composerOpen?: boolean;
|
||||
sourceLayerId?: string;
|
||||
generatedLayerId?: string;
|
||||
@@ -241,6 +252,17 @@ export type GenerateDialogState = {
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
errorMessage?: string;
|
||||
// 中文注释:标记该占位的收口只能由创建它的页面会话完成——链路是同步 HTTP、服务端没有
|
||||
// durable job,进程一死就再没有任何东西会把它推向终态。队列型占位(去除背景恒队列、
|
||||
// 图片生成默认队列)不得置位:它们的 job 在服务端继续跑,worker 会替换占位,刷新后
|
||||
// 必须原样恢复。
|
||||
requiresLiveSession?: boolean;
|
||||
// 中文注释:请求账本只存在于本机(见 perfectPixelOperationStore),布局里只留这个 id
|
||||
// 标记「该占位是一次完美像素操作」。换设备打开时标记还在、账本读不到,占位收口为可删除
|
||||
// 的失败态——这是明确设计,不得据此阻断用户删除或重做。
|
||||
perfectPixelOperationId?: string;
|
||||
perfectPixelOperation?: PerfectPixelOperationSnapshot;
|
||||
perfectPixelOperationInvalid?: boolean;
|
||||
generationStartedAt?: number;
|
||||
generationFinishedAt?: number;
|
||||
placeholder?: {
|
||||
@@ -293,6 +315,7 @@ export type CanvasHistoryActionType =
|
||||
| 'generate-image'
|
||||
| 'expand-image'
|
||||
| 'remove-background'
|
||||
| 'perfect-pixel'
|
||||
| 'split-atlas'
|
||||
| 'replace-image'
|
||||
| 'show-image'
|
||||
|
||||
@@ -78,7 +78,10 @@ import {
|
||||
getSelectedLayerIds,
|
||||
} from './ImageCanvasSelectionModel';
|
||||
import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView';
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import {
|
||||
requiresGenerationDeleteConfirmation,
|
||||
useCanvasGenerationDialogs,
|
||||
} from './useCanvasGenerationDialogs';
|
||||
import { useCanvasHistory } from './useCanvasHistory';
|
||||
import {
|
||||
useImageCanvasAssetCanvasBridge,
|
||||
@@ -99,6 +102,7 @@ import {
|
||||
DEFAULT_IMAGE_CANVAS_VIEWPORT,
|
||||
useImageCanvasViewportControls,
|
||||
} from './useImageCanvasViewportControls';
|
||||
import { useInlineGenerationPlaceholderExpiry } from './useInlineGenerationPlaceholderExpiry';
|
||||
|
||||
const TASK_FOCUS_HORIZONTAL_INSET = 28;
|
||||
const TASK_FOCUS_TOP_INSET = 82;
|
||||
@@ -297,6 +301,13 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer {
|
||||
};
|
||||
}
|
||||
|
||||
// 中文注释:服务端持久化顺序是 OSS PUT → asset object → project resource → editor asset
|
||||
// → 画布收口,非事务。看到孤儿 generating 占位只能说明最后一步没做完,前面几步可能已经
|
||||
// 成功。所以不能断言「什么都没发生」,只能指路让用户自己核对素材库。
|
||||
// 加载期剥离与页面打开期的到期清理共用这一条,两条路径对用户完全一致。
|
||||
const DEAD_INLINE_PLACEHOLDER_NOTICE =
|
||||
'上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。';
|
||||
|
||||
export function ImageCanvasEditorView({
|
||||
onProjectAccessLost,
|
||||
}: ImageCanvasEditorViewProps = {}) {
|
||||
@@ -538,6 +549,9 @@ export function ImageCanvasEditorView({
|
||||
currentUser: authUi?.user ?? null,
|
||||
});
|
||||
const refreshEditorWalletState = useCallback(() => {
|
||||
if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) {
|
||||
return;
|
||||
}
|
||||
refreshEditorWalletBalance();
|
||||
loadRechargeCenter();
|
||||
}, [loadRechargeCenter, refreshEditorWalletBalance]);
|
||||
@@ -735,6 +749,7 @@ export function ImageCanvasEditorView({
|
||||
inactiveGenerateDialogsRef,
|
||||
activeCanvasGenerationDialog,
|
||||
canvasGenerationDialogs,
|
||||
getCanvasGenerationDialogsSnapshot,
|
||||
openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById,
|
||||
@@ -1150,11 +1165,12 @@ export function ImageCanvasEditorView({
|
||||
layersRef,
|
||||
viewportRef,
|
||||
canvasGenerationDialogsRef,
|
||||
getCanvasGenerationDialogsSnapshot,
|
||||
canvasBackgroundColorRef,
|
||||
selectedLayerIdRef,
|
||||
selectedLayerIdsRef,
|
||||
}),
|
||||
[],
|
||||
[getCanvasGenerationDialogsSnapshot],
|
||||
);
|
||||
const projectPersistenceSetters = useMemo(
|
||||
() => ({
|
||||
@@ -1189,6 +1205,7 @@ export function ImageCanvasEditorView({
|
||||
appendCanvasLayersWithResources,
|
||||
applyProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
deadInlinePlaceholderDropCount,
|
||||
} = useImageCanvasProjectPersistence({
|
||||
refs: projectPersistenceRefs,
|
||||
setters: projectPersistenceSetters,
|
||||
@@ -1221,7 +1238,9 @@ export function ImageCanvasEditorView({
|
||||
) => {
|
||||
captureCanvasHistory(action);
|
||||
applyProjectSnapshot(project);
|
||||
if (action.type !== 'perfect-pixel') {
|
||||
void refreshAssetLibrary();
|
||||
}
|
||||
},
|
||||
[applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary],
|
||||
);
|
||||
@@ -1374,6 +1393,7 @@ export function ImageCanvasEditorView({
|
||||
activeCanvasGenerationDialog,
|
||||
canvasGenerationDialogs,
|
||||
openCanvasGenerationDialog,
|
||||
activateCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
hasCanvasGenerationDialogById,
|
||||
getCanvasGenerationDialogById,
|
||||
@@ -1396,12 +1416,24 @@ export function ImageCanvasEditorView({
|
||||
assetFolderId: activeUploadFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot: applyGeneratedProjectSnapshot,
|
||||
applyProjectSnapshotWithoutHistory: applyProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
refreshAssetLibrary,
|
||||
onWalletBalanceMayHaveChanged: refreshEditorWalletState,
|
||||
});
|
||||
const handleEditorAgentConfirmSent = useCallback(() => {
|
||||
generationSurface.refreshTaskList();
|
||||
}, [generationSurface]);
|
||||
const showGenerationWarning = generationSurface.showGenerationWarning;
|
||||
useEffect(() => {
|
||||
if (deadInlinePlaceholderDropCount === 0) {
|
||||
return;
|
||||
}
|
||||
showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE);
|
||||
}, [deadInlinePlaceholderDropCount, showGenerationWarning]);
|
||||
const handleInlinePlaceholdersExpired = useCallback(() => {
|
||||
showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE);
|
||||
}, [showGenerationWarning]);
|
||||
const handleExternalGenerationTasksCompleted = useCallback(
|
||||
(tasks: ExternalGenerationTaskRecord[]) => {
|
||||
if (!projectId || tasks.length === 0) {
|
||||
@@ -1501,6 +1533,10 @@ export function ImageCanvasEditorView({
|
||||
openRedrawPanel,
|
||||
openCropExpandPanel,
|
||||
removeSelectedLayerBackground,
|
||||
snapSelectedLayerToPerfectPixels,
|
||||
activeInlineGenerationDialogOwnership,
|
||||
perfectPixelLayerIds,
|
||||
pendingPerfectPixelLayerIds,
|
||||
splitSelectedIconSpritesheet,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
extractUiDesignAssets,
|
||||
@@ -1666,6 +1702,37 @@ export function ImageCanvasEditorView({
|
||||
},
|
||||
[openLayerGenerationDialog],
|
||||
);
|
||||
const removeCanvasGenerationDialog = useCallback(
|
||||
(dialogId: string) => {
|
||||
captureCanvasHistory({ type: 'delete-generation-result', count: 1 });
|
||||
removeCanvasGenerationDialogById(dialogId);
|
||||
setSelectedLayerId(null);
|
||||
setSelectedLayerIds([]);
|
||||
setImageContextMenu(null);
|
||||
setContextMenu(null);
|
||||
setActiveTool('select');
|
||||
},
|
||||
[
|
||||
captureCanvasHistory,
|
||||
removeCanvasGenerationDialogById,
|
||||
setActiveTool,
|
||||
setContextMenu,
|
||||
setImageContextMenu,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
],
|
||||
);
|
||||
const requestRemoveCanvasGenerationDialog = useCallback(
|
||||
(dialog: CanvasGenerationDialogState) => {
|
||||
if (requiresGenerationDeleteConfirmation(dialog)) {
|
||||
activateCanvasGenerationDialog(dialog);
|
||||
setPendingGenerationDeleteDialog(dialog);
|
||||
return;
|
||||
}
|
||||
removeCanvasGenerationDialog(dialog.id);
|
||||
},
|
||||
[activateCanvasGenerationDialog, removeCanvasGenerationDialog],
|
||||
);
|
||||
const contextMenuLayer =
|
||||
contextMenu?.kind === 'layer'
|
||||
? (layers.find((layer) => layer.id === contextMenu.layerId) ?? null)
|
||||
@@ -1711,6 +1778,7 @@ export function ImageCanvasEditorView({
|
||||
selectSingleLayer,
|
||||
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
|
||||
onDeleteGenerationDialogSideEffects: removeCanvasGenerationDialogById,
|
||||
onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog,
|
||||
exportLayerImage,
|
||||
onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning,
|
||||
});
|
||||
@@ -1810,37 +1878,15 @@ export function ImageCanvasEditorView({
|
||||
(layerId: string | null) => deleteLayerByIdRef.current(layerId),
|
||||
[],
|
||||
);
|
||||
const removeCanvasGenerationDialog = useCallback(
|
||||
(dialogId: string) => {
|
||||
captureCanvasHistory({ type: 'delete-generation-result', count: 1 });
|
||||
removeCanvasGenerationDialogById(dialogId);
|
||||
setSelectedLayerId(null);
|
||||
setSelectedLayerIds([]);
|
||||
setImageContextMenu(null);
|
||||
setContextMenu(null);
|
||||
setActiveTool('select');
|
||||
},
|
||||
[
|
||||
captureCanvasHistory,
|
||||
removeCanvasGenerationDialogById,
|
||||
setActiveTool,
|
||||
setContextMenu,
|
||||
setImageContextMenu,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
],
|
||||
);
|
||||
const requestRemoveCanvasGenerationDialog = useCallback(
|
||||
(dialog: CanvasGenerationDialogState) => {
|
||||
if (dialog.status === 'generating') {
|
||||
activateCanvasGenerationDialog(dialog);
|
||||
setPendingGenerationDeleteDialog(dialog);
|
||||
return;
|
||||
}
|
||||
removeCanvasGenerationDialog(dialog.id);
|
||||
},
|
||||
[activateCanvasGenerationDialog, removeCanvasGenerationDialog],
|
||||
);
|
||||
// 中文注释:加载期的剥离只跑一次,当时未到期而被保留的孤儿占位需要这里补上到期清理,
|
||||
// 否则它会一直转到用户下一次加载。两条路径共用同一条文案,用户感知一致。
|
||||
useInlineGenerationPlaceholderExpiry({
|
||||
canvasGenerationDialogs,
|
||||
activeInlineGenerationDialogOwnership,
|
||||
removeCanvasGenerationDialogById,
|
||||
onPlaceholdersExpired: handleInlinePlaceholdersExpired,
|
||||
});
|
||||
|
||||
const confirmRemoveGeneratingDialog = useCallback(() => {
|
||||
const dialog = pendingGenerationDeleteDialog;
|
||||
if (!dialog) {
|
||||
@@ -2286,6 +2332,8 @@ export function ImageCanvasEditorView({
|
||||
quickEditSelectionSourceLayer,
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
perfectPixelLayerIds,
|
||||
pendingPerfectPixelLayerIds,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
uploadDropTarget,
|
||||
contextMenu,
|
||||
@@ -2339,6 +2387,9 @@ export function ImageCanvasEditorView({
|
||||
onOpenRedrawPanel: openRedrawPanel,
|
||||
onOpenCropExpandPanel: openCropExpandPanel,
|
||||
onRemoveBackground: removeSelectedLayerBackground,
|
||||
onPerfectPixel: (layer: CanvasLayer) => {
|
||||
void snapSelectedLayerToPerfectPixels(layer);
|
||||
},
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => {
|
||||
void flushProjectPersistence().then(() =>
|
||||
splitSelectedIconSpritesheet(layer),
|
||||
|
||||
@@ -17,7 +17,6 @@ function mockStateSetter<T>() {
|
||||
return vi.fn() as unknown as Dispatch<SetStateAction<T>>;
|
||||
}
|
||||
|
||||
|
||||
function createComposerProps(
|
||||
generateDialog: GenerateDialogState,
|
||||
overrides: Partial<
|
||||
@@ -149,6 +148,118 @@ function createBackgroundMusicPromptAssistStub(): NonNullable<
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationComposerView', () => {
|
||||
it.each([
|
||||
{
|
||||
status: 'pending-confirmation' as const,
|
||||
message: '结果尚未确认,系统不会自动重复提交。',
|
||||
},
|
||||
{
|
||||
status: 'failed' as const,
|
||||
message: '完美像素请求尚未发出,请重试同一操作。',
|
||||
},
|
||||
])('$status 的完美像素操作只展示原操作重试', ({ status, message }) => {
|
||||
const dialogId = `dialog-perfect-pixel-${status}`;
|
||||
const onRetryPerfectPixelOperation = vi.fn();
|
||||
const onSubmitImageGeneration = vi.fn();
|
||||
|
||||
renderComposer(
|
||||
{
|
||||
id: dialogId,
|
||||
mode: 'generate',
|
||||
prompt: '不应重新提交的普通提示词',
|
||||
status,
|
||||
composerOpen: true,
|
||||
imageModel: 'gpt-image-2',
|
||||
...(status === 'failed' ? { errorMessage: message } : {}),
|
||||
perfectPixelOperation: {
|
||||
version: 1,
|
||||
kind: 'perfect-pixel',
|
||||
operationId: dialogId,
|
||||
taskId: `pixel-art-snap-${dialogId}`,
|
||||
request: {
|
||||
sourceImageSrc: 'ref:project-resource:resource-source',
|
||||
projectId: 'project-1',
|
||||
sourceResourceId: 'resource-source',
|
||||
assetKind: 'character',
|
||||
assetLabel: '角色 · 完美像素',
|
||||
canvasCompletion: {
|
||||
dialogId,
|
||||
title: '角色 · 完美像素',
|
||||
placeholder: {
|
||||
x: 100,
|
||||
y: 120,
|
||||
width: 320,
|
||||
height: 320,
|
||||
originalWidth: 640,
|
||||
originalHeight: 640,
|
||||
},
|
||||
},
|
||||
},
|
||||
submittedAt: 1_700_000_000_000,
|
||||
reconcileUntil: 1_700_000_120_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
onRetryPerfectPixelOperation,
|
||||
onSubmitImageGeneration,
|
||||
},
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '完美像素操作' });
|
||||
expect(within(panel).getByText(message)).toBeTruthy();
|
||||
expect(within(panel).queryByRole('textbox')).toBeNull();
|
||||
expect(within(panel).queryByText('不应重新提交的普通提示词')).toBeNull();
|
||||
expect(within(panel).queryByText('gpt-image-2')).toBeNull();
|
||||
expect(within(panel).queryByRole('button', { name: '修改' })).toBeNull();
|
||||
|
||||
const retryButton = within(panel).getByRole('button', {
|
||||
name: '重试同一完美像素操作',
|
||||
});
|
||||
expect(within(panel).getAllByRole('button')).toEqual([retryButton]);
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
|
||||
expect(onRetryPerfectPixelOperation).toHaveBeenCalledOnce();
|
||||
expect(onRetryPerfectPixelOperation).toHaveBeenCalledWith(dialogId);
|
||||
expect(onSubmitImageGeneration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('无效完美像素标记只读展示错误,不暴露普通生成提交入口', () => {
|
||||
const onRetryPerfectPixelOperation = vi.fn();
|
||||
const onSubmitImageGeneration = vi.fn();
|
||||
|
||||
renderComposer(
|
||||
{
|
||||
id: 'dialog-perfect-pixel-invalid',
|
||||
mode: 'generate',
|
||||
prompt: '不得重新生成',
|
||||
status: 'failed',
|
||||
composerOpen: true,
|
||||
imageModel: 'gpt-image-2',
|
||||
perfectPixelOperationInvalid: true,
|
||||
errorMessage: '完美像素操作快照身份不匹配。',
|
||||
},
|
||||
{
|
||||
onRetryPerfectPixelOperation,
|
||||
onSubmitImageGeneration,
|
||||
},
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '完美像素操作' });
|
||||
expect(within(panel).getByRole('alert').textContent).toBe(
|
||||
'完美像素操作快照身份不匹配。',
|
||||
);
|
||||
expect(within(panel).queryByRole('textbox')).toBeNull();
|
||||
expect(within(panel).queryByRole('button')).toBeNull();
|
||||
expect(within(panel).queryByText('不得重新生成')).toBeNull();
|
||||
expect(within(panel).queryByText('gpt-image-2')).toBeNull();
|
||||
|
||||
fireEvent.submit(panel);
|
||||
|
||||
expect(onRetryPerfectPixelOperation).not.toHaveBeenCalled();
|
||||
expect(onSubmitImageGeneration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('让快速编辑显示提示词、尺寸和模型选择', () => {
|
||||
renderComposer({
|
||||
mode: 'quick-edit',
|
||||
@@ -172,8 +283,9 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
expect(panel.className).not.toContain(
|
||||
'image-canvas-editor__quick-edit-panel',
|
||||
);
|
||||
expect(within(panel).getByRole('textbox', { name: '快速编辑提示词' }))
|
||||
.toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: '快速编辑提示词' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).queryByRole('button', { name: '添加参考图' }),
|
||||
).toBeNull();
|
||||
@@ -195,22 +307,24 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
|
||||
it('让生成UI设计图面板复用普通图片生成面板的纵向结构', () => {
|
||||
const setGenerateDialog = vi.fn();
|
||||
renderComposer({
|
||||
mode: 'ui-design',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
uiDesignSpecReference: null,
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
}, {
|
||||
isUiDesignSpecMenuOpen: true,
|
||||
setGenerateDialog:
|
||||
setGenerateDialog as unknown as Dispatch<
|
||||
renderComposer(
|
||||
{
|
||||
mode: 'ui-design',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
uiDesignSpecReference: null,
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
},
|
||||
{
|
||||
isUiDesignSpecMenuOpen: true,
|
||||
setGenerateDialog: setGenerateDialog as unknown as Dispatch<
|
||||
SetStateAction<GenerateDialogState | null>
|
||||
>,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成UI设计图' });
|
||||
expect(
|
||||
@@ -233,9 +347,12 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
||||
).toBeTruthy();
|
||||
fireEvent.change(within(panel).getByRole('textbox', { name: 'UI设计要求' }), {
|
||||
target: { value: '主界面和结算弹窗' },
|
||||
});
|
||||
fireEvent.change(
|
||||
within(panel).getByRole('textbox', { name: 'UI设计要求' }),
|
||||
{
|
||||
target: { value: '主界面和结算弹窗' },
|
||||
},
|
||||
);
|
||||
expect(setGenerateDialog).toHaveBeenCalled();
|
||||
const menu = screen.getByRole('menu', { name: '参考图来源' });
|
||||
expect(
|
||||
@@ -311,47 +428,56 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
['publication-cover-image', '游戏首图', '720 x 540'],
|
||||
['publication-detail-gallery', '详情五图', '720 x 1280'],
|
||||
['publication-promo-poster', '运营海报', '1280 x 720'],
|
||||
] as const)('让%s 宣发素材面板对齐生成角色面板结构', (workflowId, label, sizeLabel) => {
|
||||
renderComposer({
|
||||
mode: 'publication',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
publicationWorkflowId: workflowId,
|
||||
publicationGameInfo: {
|
||||
gameName: '',
|
||||
gameCategories: '',
|
||||
gameDescription: '',
|
||||
},
|
||||
publicationReferences: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
});
|
||||
] as const)(
|
||||
'让%s 宣发素材面板对齐生成角色面板结构',
|
||||
(workflowId, label, sizeLabel) => {
|
||||
renderComposer({
|
||||
mode: 'publication',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
publicationWorkflowId: workflowId,
|
||||
publicationGameInfo: {
|
||||
gameName: '',
|
||||
gameCategories: '',
|
||||
gameDescription: '',
|
||||
},
|
||||
publicationReferences: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: `${label}生成卡片` });
|
||||
expect(panel.className).toContain('image-canvas-editor__character-composer');
|
||||
expect(panel.className).toContain('image-canvas-editor__publication-composer');
|
||||
expect(
|
||||
panel.firstElementChild?.className.includes(
|
||||
'image-canvas-editor__reference-strip',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(within(panel).getByRole('button', { name: '添加参考图' })).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: `${label}游戏名` }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: `${label}一句话描述游戏` }),
|
||||
).toBeTruthy();
|
||||
expect(panel.textContent).toContain(sizeLabel);
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
||||
).toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '生成' }).textContent).toBe(
|
||||
'生成3泥点',
|
||||
);
|
||||
});
|
||||
const panel = screen.getByRole('dialog', { name: `${label}生成卡片` });
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__character-composer',
|
||||
);
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__publication-composer',
|
||||
);
|
||||
expect(
|
||||
panel.firstElementChild?.className.includes(
|
||||
'image-canvas-editor__reference-strip',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '添加参考图' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: `${label}游戏名` }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: `${label}一句话描述游戏` }),
|
||||
).toBeTruthy();
|
||||
expect(panel.textContent).toContain(sizeLabel);
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '生成' }).textContent,
|
||||
).toBe('生成3泥点');
|
||||
},
|
||||
);
|
||||
|
||||
it('恢复宣发素材生成卡片的字段和参考图', () => {
|
||||
renderComposer({
|
||||
@@ -379,19 +505,25 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '运营海报生成卡片' });
|
||||
expect(
|
||||
(within(panel).getByRole('textbox', {
|
||||
name: '运营海报游戏名',
|
||||
}) as HTMLInputElement).value,
|
||||
(
|
||||
within(panel).getByRole('textbox', {
|
||||
name: '运营海报游戏名',
|
||||
}) as HTMLInputElement
|
||||
).value,
|
||||
).toBe('马戏团午夜惊魂');
|
||||
expect(
|
||||
(within(panel).getByRole('textbox', {
|
||||
name: '运营海报游戏分类',
|
||||
}) as HTMLInputElement).value,
|
||||
(
|
||||
within(panel).getByRole('textbox', {
|
||||
name: '运营海报游戏分类',
|
||||
}) as HTMLInputElement
|
||||
).value,
|
||||
).toBe('非对称对抗');
|
||||
expect(
|
||||
(within(panel).getByRole('textbox', {
|
||||
name: '运营海报一句话描述游戏',
|
||||
}) as HTMLTextAreaElement).value,
|
||||
(
|
||||
within(panel).getByRole('textbox', {
|
||||
name: '运营海报一句话描述游戏',
|
||||
}) as HTMLTextAreaElement
|
||||
).value,
|
||||
).toBe('找到钥匙,开门逃离马戏团');
|
||||
expect(within(panel).getByLabelText('首图参考')).toBeTruthy();
|
||||
expect(
|
||||
@@ -542,10 +674,14 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
<>
|
||||
<ImageCanvasGenerationComposerView
|
||||
{...createComposerProps(dialog)}
|
||||
setGenerateDialog={setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>}
|
||||
setGenerateDialog={
|
||||
setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>
|
||||
}
|
||||
/>
|
||||
<output aria-label="当前视频模型">{dialog.videoModel}</output>
|
||||
<output aria-label="当前视频时长">{dialog.videoDurationSeconds}</output>
|
||||
<output aria-label="当前视频时长">
|
||||
{dialog.videoDurationSeconds}
|
||||
</output>
|
||||
<output aria-label="当前视频清晰度">{dialog.videoResolution}</output>
|
||||
<output aria-label="当前视频占位">
|
||||
{dialog.placeholder
|
||||
@@ -577,9 +713,9 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '模型 Seedance 2.0 Fast' }),
|
||||
).toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '生成视频' }).textContent).toBe(
|
||||
'生成40泥点',
|
||||
);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '生成视频' }).textContent,
|
||||
).toBe('生成40泥点');
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
@@ -611,15 +747,18 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
.querySelector('.image-canvas-editor__ratio-wireframe')
|
||||
?.getAttribute('data-ratio'),
|
||||
).toBe('21:9');
|
||||
fireEvent.change(within(paramsPanel).getByRole('slider', { name: '视频时长' }), {
|
||||
target: { value: '5' },
|
||||
});
|
||||
fireEvent.click(within(paramsPanel).getByRole('button', { name: '清晰度 720p' }));
|
||||
fireEvent.change(
|
||||
within(paramsPanel).getByRole('slider', { name: '视频时长' }),
|
||||
{
|
||||
target: { value: '5' },
|
||||
},
|
||||
);
|
||||
fireEvent.click(
|
||||
within(paramsPanel).getByRole('button', { name: '清晰度 720p' }),
|
||||
);
|
||||
fireEvent.click(within(paramsPanel).getByRole('button', { name: '静音' }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '视频参数选项' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('menu', { name: '视频参数选项' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前视频时长').textContent).toBe('5');
|
||||
expect(screen.getByLabelText('当前视频清晰度').textContent).toBe('720p');
|
||||
expect(screen.getByLabelText('当前视频占位').textContent).toBe(
|
||||
@@ -637,10 +776,16 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
'生成100泥点',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '模型 Seedance 2.0 Fast' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '模型 Seedance 2.0 Fast' }),
|
||||
);
|
||||
const modelPanel = screen.getByRole('menu', { name: '视频模型选项' });
|
||||
expect(within(modelPanel).queryByRole('button', { name: /Veo/i })).toBeNull();
|
||||
fireEvent.click(within(modelPanel).getByRole('button', { name: 'Kling 3.0' }));
|
||||
expect(
|
||||
within(modelPanel).queryByRole('button', { name: /Veo/i }),
|
||||
).toBeNull();
|
||||
fireEvent.click(
|
||||
within(modelPanel).getByRole('button', { name: 'Kling 3.0' }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('menu', { name: '视频模型选项' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前视频模型').textContent).toBe('kling3.0');
|
||||
@@ -709,9 +854,9 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
expect(within(panel).queryByText('单次')).toBeNull();
|
||||
expect(within(panel).queryByText('循环')).toBeNull();
|
||||
expect(within(panel).queryByText(/BPM/u)).toBeNull();
|
||||
expect(within(panel).getByRole('button', { name: '生成游戏音效' }).textContent).toBe(
|
||||
'生成5泥点',
|
||||
);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '生成游戏音效' }).textContent,
|
||||
).toBe('生成5泥点');
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '音效时长 5秒' }),
|
||||
@@ -731,9 +876,7 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
fireEvent.change(durationSlider, { target: { value: '8' } });
|
||||
|
||||
expect(screen.getByLabelText('当前音效时长').textContent).toBe('8');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '音效时长 8秒' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '音效时长 8秒' })).toBeTruthy();
|
||||
|
||||
fireEvent.submit(panel);
|
||||
expect(onSubmitImageGeneration).toHaveBeenCalledWith(
|
||||
@@ -780,9 +923,10 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
);
|
||||
expect(within(panel).getByText('Suno')).toBeTruthy();
|
||||
expect(within(panel).queryByText('make_instrumental')).toBeNull();
|
||||
expect(within(panel).getByRole('button', { name: '生成游戏背景音乐' }).textContent).toBe(
|
||||
'生成12泥点',
|
||||
);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '生成游戏背景音乐' })
|
||||
.textContent,
|
||||
).toBe('生成12泥点');
|
||||
});
|
||||
it('生成规范参考图点击先弹来源菜单,不直接打开上传', () => {
|
||||
const onRequestUpload = vi.fn();
|
||||
@@ -817,7 +961,11 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
expect(onRequestUpload).not.toHaveBeenCalled();
|
||||
expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled();
|
||||
const menu = screen.getByRole('menu', { name: '参考图来源' });
|
||||
expect(within(menu).getByRole('menuitem', { name: '从画布中选择' })).toBeTruthy();
|
||||
expect(within(menu).getByRole('menuitem', { name: '上传图片' })).toBeTruthy();
|
||||
expect(
|
||||
within(menu).getByRole('menuitem', { name: '从画布中选择' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(menu).getByRole('menuitem', { name: '上传图片' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,9 +117,7 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
setIsUiDesignSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingGenerationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingQuickEditReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingPublicationReferenceFromCanvas: Dispatch<
|
||||
SetStateAction<boolean>
|
||||
>;
|
||||
setIsPickingPublicationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingIconSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -131,6 +129,7 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
onSubmitQuickEdit: () => void;
|
||||
onSubmitCropExpand: () => void;
|
||||
onSubmitCharacterAnimation: () => void;
|
||||
onRetryPerfectPixelOperation?: (dialogId: string) => void;
|
||||
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
|
||||
onUpdateIconDescriptionText: (value: string) => void;
|
||||
onUpdateCharacterAnimationDuration: (frameCountValue: string) => void;
|
||||
@@ -139,6 +138,66 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
onSpecMenuPointerLeave?: () => void;
|
||||
};
|
||||
|
||||
function PerfectPixelOperationPanel({
|
||||
dialog,
|
||||
style,
|
||||
onRetry,
|
||||
}: {
|
||||
dialog: GenerateDialogState;
|
||||
style: CSSProperties;
|
||||
onRetry?: (dialogId: string) => void;
|
||||
}) {
|
||||
const isPending = dialog.status === 'pending-confirmation';
|
||||
const isPreparedFailure =
|
||||
dialog.status === 'failed' && Boolean(dialog.perfectPixelOperation);
|
||||
const isGenerating = dialog.status === 'generating';
|
||||
const isInvalid = dialog.perfectPixelOperationInvalid === true;
|
||||
const message = isInvalid
|
||||
? (dialog.errorMessage ?? '完美像素操作快照无效,禁止自动重试。')
|
||||
: isPending
|
||||
? (dialog.errorMessage ?? '结果尚未确认,系统不会自动重复提交。')
|
||||
: isGenerating
|
||||
? '完美像素处理中'
|
||||
: dialog.status === 'failed'
|
||||
? (dialog.errorMessage ?? '完美像素处理失败')
|
||||
: '完美像素处理已完成';
|
||||
return (
|
||||
<section
|
||||
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--perfect-pixel"
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-label="完美像素操作"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<strong>完美像素</strong>
|
||||
<PlatformStatusMessage
|
||||
tone={isInvalid || dialog.status === 'failed' ? 'error' : 'info'}
|
||||
surface="platform"
|
||||
size="xs"
|
||||
className="image-canvas-editor__generate-status"
|
||||
role={isInvalid || dialog.status === 'failed' ? 'alert' : 'status'}
|
||||
>
|
||||
{message}
|
||||
</PlatformStatusMessage>
|
||||
{!isInvalid &&
|
||||
(isPending || isPreparedFailure) &&
|
||||
dialog.id &&
|
||||
dialog.perfectPixelOperation ? (
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={!onRetry}
|
||||
onClick={() => onRetry?.(dialog.id!)}
|
||||
>
|
||||
重试同一完美像素操作
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPortalMenuStyle(
|
||||
anchor: HTMLElement | null,
|
||||
placement: 'above' | 'below',
|
||||
@@ -343,7 +402,8 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
updateVideoDialog({
|
||||
videoModel: model,
|
||||
videoResolution:
|
||||
model === VIDEO_MODEL_SEEDANCE_2_FAST && dialog.videoResolution === '1080p'
|
||||
model === VIDEO_MODEL_SEEDANCE_2_FAST &&
|
||||
dialog.videoResolution === '1080p'
|
||||
? '720p'
|
||||
: dialog.videoResolution,
|
||||
...(isSeedanceVideoModel(model) ? {} : { generationReferences: [] }),
|
||||
@@ -493,7 +553,8 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
{EDITOR_VIDEO_RESOLUTION_OPTIONS.map((option) => {
|
||||
const available =
|
||||
currentModel.value !== VIDEO_MODEL_SEEDANCE_2_FAST ||
|
||||
currentModel.value !==
|
||||
VIDEO_MODEL_SEEDANCE_2_FAST ||
|
||||
option !== '1080p';
|
||||
return (
|
||||
<VideoOptionChoice
|
||||
@@ -527,8 +588,7 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
disabled={isGenerating}
|
||||
onClick={() =>
|
||||
updateVideoDialog({
|
||||
videoSound:
|
||||
soundMode === 'off' ? 'on' : 'off',
|
||||
videoSound: soundMode === 'off' ? 'on' : 'off',
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -756,7 +816,9 @@ function ImageCanvasSoundEffectGenerationComposerView({
|
||||
};
|
||||
|
||||
const updateSoundDuration = (durationSeconds: number) => {
|
||||
updateAudioDialog({ soundDurationSeconds: normalizeSoundDuration(durationSeconds) });
|
||||
updateAudioDialog({
|
||||
soundDurationSeconds: normalizeSoundDuration(durationSeconds),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -777,7 +839,9 @@ function ImageCanvasSoundEffectGenerationComposerView({
|
||||
aria-label="prompt"
|
||||
value={dialog.prompt}
|
||||
disabled={isGenerating}
|
||||
placeholder="你希望生成什么音效?"
|
||||
placeholder={
|
||||
isSoundEffect ? '你希望生成什么音效?' : '你希望生成什么音乐?'
|
||||
}
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onChange={(event) => updateAudioDialog({ prompt: event.target.value })}
|
||||
/>
|
||||
@@ -945,6 +1009,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
onSubmitQuickEdit,
|
||||
onSubmitCropExpand,
|
||||
onSubmitCharacterAnimation,
|
||||
onRetryPerfectPixelOperation,
|
||||
onUpdateSpecFormValue,
|
||||
onUpdateIconDescriptionText,
|
||||
onUpdateCharacterAnimationDuration,
|
||||
@@ -954,7 +1019,10 @@ export function ImageCanvasGenerationComposerView({
|
||||
}: ImageCanvasGenerationComposerViewProps) {
|
||||
const backgroundMusicDialog =
|
||||
toBackgroundMusicGenerationDialog(generateDialog);
|
||||
|
||||
const isPerfectPixelDialog = Boolean(
|
||||
generateDialog?.perfectPixelOperation ||
|
||||
generateDialog?.perfectPixelOperationInvalid,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{isSpecMenuOpen
|
||||
@@ -982,7 +1050,19 @@ export function ImageCanvasGenerationComposerView({
|
||||
)
|
||||
: null}
|
||||
|
||||
{(generateDialog?.mode === 'generate' ||
|
||||
{isPerfectPixelDialog &&
|
||||
generateDialog &&
|
||||
generateDialog.composerOpen !== false &&
|
||||
generationComposerStyle ? (
|
||||
<PerfectPixelOperationPanel
|
||||
dialog={generateDialog}
|
||||
style={generationComposerStyle}
|
||||
onRetry={onRetryPerfectPixelOperation}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isPerfectPixelDialog &&
|
||||
(generateDialog?.mode === 'generate' ||
|
||||
generateDialog?.mode === 'quick-edit') &&
|
||||
generateDialog.composerOpen !== false &&
|
||||
generationComposerStyle ? (
|
||||
@@ -1041,9 +1121,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
publicationReferenceButtonRef={publicationReferenceButtonRef}
|
||||
isPublicationReferenceMenuOpen={isPublicationReferenceMenuOpen}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
setIsPublicationReferenceMenuOpen={
|
||||
setIsPublicationReferenceMenuOpen
|
||||
}
|
||||
setIsPublicationReferenceMenuOpen={setIsPublicationReferenceMenuOpen}
|
||||
setIsPickingPublicationReferenceFromCanvas={
|
||||
setIsPickingPublicationReferenceFromCanvas
|
||||
}
|
||||
@@ -1230,9 +1308,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{cropExpandPanel &&
|
||||
cropExpandSourceLayer &&
|
||||
cropExpandPanelStyle ? (
|
||||
{cropExpandPanel && cropExpandSourceLayer && cropExpandPanelStyle ? (
|
||||
<ImageCanvasCropExpandPanelView
|
||||
panel={cropExpandPanel}
|
||||
style={cropExpandPanelStyle}
|
||||
|
||||
@@ -625,6 +625,12 @@ describe('ImageCanvasHistoryModel', () => {
|
||||
expect(isProtectedCanvasHistoryAction({ type: 'replace-image' })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(formatCanvasHistoryAction({ type: 'perfect-pixel' })).toBe(
|
||||
'完美像素',
|
||||
);
|
||||
expect(isProtectedCanvasHistoryAction({ type: 'perfect-pixel' })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isProtectedCanvasHistoryAction({ type: 'move-image' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ const CANVAS_HISTORY_ACTION_LABELS: Record<
|
||||
'generate-image': '生成图片',
|
||||
'expand-image': '扩展图片',
|
||||
'remove-background': '移除背景',
|
||||
'perfect-pixel': '完美像素',
|
||||
'split-atlas': '拆分图集',
|
||||
'replace-image': '替换图片',
|
||||
'show-image': '显示图片',
|
||||
@@ -46,6 +47,7 @@ const PROTECTED_CANVAS_HISTORY_ACTION_TYPES = new Set<
|
||||
'generate-image',
|
||||
'expand-image',
|
||||
'remove-background',
|
||||
'perfect-pixel',
|
||||
'split-atlas',
|
||||
'replace-image',
|
||||
]);
|
||||
|
||||
@@ -78,7 +78,11 @@ function syncPanelFromDialogUpdate(
|
||||
model: normalizedDialog.imageModel ?? normalizedPanel.model,
|
||||
quickEditReferences: normalizedDialog.generationReferences,
|
||||
assetLabel: normalizedDialog.assetLabel,
|
||||
status: normalizedDialog.status,
|
||||
// 中文注释:dialog 由 createQuickEditDialog 从面板自身派生,组合器只会做
|
||||
// failed→idle 的重置,不会写入 'pending-confirmation' 这类完美像素专属状态。
|
||||
// 因此归一化后的 dialog 状态恒等于归一化后的面板状态,直接取面板侧即可,
|
||||
// 也让面板的三态联合类型不必在这里做运行时收窄。
|
||||
status: normalizedPanel.status,
|
||||
errorMessage: normalizedDialog.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
type EditorBackgroundRemovalInput,
|
||||
type EditorBackgroundRemovalResult,
|
||||
type EditorPixelArtSnapInput,
|
||||
type EditorPixelArtSnapResult,
|
||||
removeEditorImageBackground,
|
||||
snapEditorImageToPixelArt,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
|
||||
export type CropExpandInsets = {
|
||||
@@ -222,3 +225,9 @@ export async function removeImageBackground(
|
||||
typeof input === 'string' ? { sourceImageSrc: input } : input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function snapImageToPerfectPixels(
|
||||
input: EditorPixelArtSnapInput,
|
||||
): Promise<EditorPixelArtSnapResult> {
|
||||
return snapEditorImageToPixelArt(input);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ function renderSelectedToolbar(
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
onOpenCropExpandPanel: vi.fn(),
|
||||
onRemoveBackground: vi.fn(),
|
||||
onPerfectPixel: vi.fn(),
|
||||
isPerfectPixelProcessing: false,
|
||||
isPerfectPixelPendingConfirmation: false,
|
||||
isSplittingIconSpritesheet: false,
|
||||
isPersistingAssetKind: false,
|
||||
onSplitIconSpritesheet: vi.fn(),
|
||||
@@ -66,6 +69,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
'快速编辑',
|
||||
'裁扩按钮',
|
||||
'去除背景按钮',
|
||||
'完美像素',
|
||||
'改造',
|
||||
'下载按钮',
|
||||
]);
|
||||
@@ -75,6 +79,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '去除背景按钮' }),
|
||||
);
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '完美像素' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '改造' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '下载按钮' }));
|
||||
|
||||
@@ -85,6 +90,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
props.selectedLayer,
|
||||
);
|
||||
expect(props.onRemoveBackground).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(props.onPerfectPixel).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(
|
||||
@@ -104,6 +110,19 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
.getByRole('button', { name: '去除背景按钮' })
|
||||
.querySelector('.lucide-image-off'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '完美像素' })
|
||||
.querySelector('.lucide-grid-2x2'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '完美像素' }).textContent,
|
||||
).toContain('完美像素');
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '完美像素' })
|
||||
.getAttribute('title'),
|
||||
).toBe('自动识别并规整像素网格');
|
||||
});
|
||||
|
||||
it('renders UI design asset extraction after remove background', () => {
|
||||
@@ -119,6 +138,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
'快速编辑',
|
||||
'裁扩按钮',
|
||||
'去除背景按钮',
|
||||
'完美像素',
|
||||
'提取素材',
|
||||
'改造',
|
||||
'下载按钮',
|
||||
@@ -142,6 +162,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
'快速编辑',
|
||||
'裁扩按钮',
|
||||
'去除背景按钮',
|
||||
'完美像素',
|
||||
'拆分图集',
|
||||
'改造',
|
||||
'下载按钮',
|
||||
@@ -184,6 +205,74 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a disabled loading state while perfect pixel is processing', () => {
|
||||
const props = renderSelectedToolbar({
|
||||
isPerfectPixelProcessing: true,
|
||||
});
|
||||
const button = screen.getByRole('button', {
|
||||
name: '完美像素处理中',
|
||||
});
|
||||
|
||||
expect(button.getAttribute('aria-busy')).toBe('true');
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(
|
||||
button.querySelector('.lucide-loader-circle.animate-spin'),
|
||||
).toBeTruthy();
|
||||
expect(button.querySelector('.lucide-grid-2x2')).toBeNull();
|
||||
expect(button.textContent).toContain('处理中');
|
||||
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(props.onPerfectPixel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a distinct disabled state while perfect pixel is pending confirmation', () => {
|
||||
const props = renderSelectedToolbar({
|
||||
isPerfectPixelPendingConfirmation: true,
|
||||
});
|
||||
const button = screen.getByRole('button', {
|
||||
name: '完美像素结果待确认',
|
||||
});
|
||||
|
||||
expect(button.getAttribute('title')).toBe(
|
||||
'结果待确认,双击原占位继续核对或重试',
|
||||
);
|
||||
expect(button.getAttribute('aria-busy')).toBe('false');
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(button.textContent).toContain('待确认');
|
||||
expect(button.textContent).not.toContain('处理中');
|
||||
expect(button.querySelector('.lucide-grid-2x2')).toBeTruthy();
|
||||
expect(button.querySelector('.lucide-loader-circle')).toBeNull();
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(props.onPerfectPixel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not invoke perfect pixel while the selected asset kind is persisting', () => {
|
||||
// 中文注释:请求同时带 assetKind 与 sourceResourceId,本地类型已改但资源尚未落库时
|
||||
// 两者不一致,后端会直接 400 并留下失败占位。名称与拆分图集按钮的「素材类型保存中」
|
||||
// 必须区分开——icon-spritesheet 图层上两个按钮会同时进入保存态。
|
||||
const layer = createLayer({ assetKind: 'icon-spritesheet' });
|
||||
const props = renderSelectedToolbar({
|
||||
selectedLayer: layer,
|
||||
isPersistingAssetKind: true,
|
||||
});
|
||||
const button = screen.getByRole('button', {
|
||||
name: '完美像素等待素材类型保存',
|
||||
});
|
||||
|
||||
expect(button.getAttribute('aria-busy')).toBe('true');
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(button.querySelector('.lucide-grid-2x2')).toBeNull();
|
||||
expect(button.textContent).toContain('保存中');
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(props.onPerfectPixel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not invoke atlas splitting while the selected asset kind is persisting', () => {
|
||||
const layer = createLayer({ assetKind: 'icon-spritesheet' });
|
||||
const props = renderSelectedToolbar({
|
||||
@@ -227,6 +316,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(
|
||||
within(toolbar).queryByRole('button', { name: '去除背景按钮' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(toolbar).queryByRole('button', { name: '完美像素' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(toolbar).queryByRole('button', { name: '生成动画' }),
|
||||
).toBeNull();
|
||||
@@ -255,6 +347,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(
|
||||
within(videoToolbar).queryByRole('button', { name: '去除背景按钮' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(videoToolbar).queryByRole('button', { name: '完美像素' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(videoToolbar)
|
||||
.getAllByRole('button')
|
||||
@@ -278,6 +373,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(
|
||||
within(actionToolbar).queryByRole('button', { name: '去除背景按钮' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(actionToolbar).queryByRole('button', { name: '完美像素' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(actionToolbar)
|
||||
.getAllByRole('button')
|
||||
@@ -341,6 +439,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
onOpenRedrawPanel={vi.fn()}
|
||||
onOpenCropExpandPanel={vi.fn()}
|
||||
onRemoveBackground={vi.fn()}
|
||||
onPerfectPixel={vi.fn()}
|
||||
isPerfectPixelProcessing={false}
|
||||
isPerfectPixelPendingConfirmation={false}
|
||||
isSplittingIconSpritesheet={false}
|
||||
isPersistingAssetKind={false}
|
||||
onSplitIconSpritesheet={vi.fn()}
|
||||
@@ -360,6 +461,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
onOpenRedrawPanel={vi.fn()}
|
||||
onOpenCropExpandPanel={vi.fn()}
|
||||
onRemoveBackground={vi.fn()}
|
||||
onPerfectPixel={vi.fn()}
|
||||
isPerfectPixelProcessing={false}
|
||||
isPerfectPixelPendingConfirmation={false}
|
||||
isSplittingIconSpritesheet={false}
|
||||
isPersistingAssetKind={false}
|
||||
onSplitIconSpritesheet={vi.fn()}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Crop,
|
||||
Download,
|
||||
Grid2X2,
|
||||
ImageOff,
|
||||
Loader2,
|
||||
PersonStanding,
|
||||
@@ -22,6 +23,9 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
onOpenRedrawPanel: (layer: CanvasLayer) => void;
|
||||
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
|
||||
onRemoveBackground: (layer: CanvasLayer) => void;
|
||||
onPerfectPixel: (layer: CanvasLayer) => void;
|
||||
isPerfectPixelProcessing: boolean;
|
||||
isPerfectPixelPendingConfirmation: boolean;
|
||||
isSplittingIconSpritesheet: boolean;
|
||||
isPersistingAssetKind: boolean;
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
|
||||
@@ -37,6 +41,9 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onOpenRedrawPanel,
|
||||
onOpenCropExpandPanel,
|
||||
onRemoveBackground,
|
||||
onPerfectPixel,
|
||||
isPerfectPixelProcessing,
|
||||
isPerfectPixelPendingConfirmation,
|
||||
isSplittingIconSpritesheet,
|
||||
isPersistingAssetKind,
|
||||
onSplitIconSpritesheet,
|
||||
@@ -120,6 +127,57 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
icon={ImageOff}
|
||||
onClick={() => onRemoveBackground(selectedLayer)}
|
||||
/>
|
||||
<PlatformIconButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
|
||||
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
|
||||
label={
|
||||
isPersistingAssetKind
|
||||
? '完美像素等待素材类型保存'
|
||||
: isPerfectPixelProcessing
|
||||
? '完美像素处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '完美像素结果待确认'
|
||||
: '完美像素'
|
||||
}
|
||||
title={
|
||||
isPersistingAssetKind
|
||||
? '完美像素等待素材类型保存'
|
||||
: isPerfectPixelProcessing
|
||||
? '完美像素处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '结果待确认,双击原占位继续核对或重试'
|
||||
: '自动识别并规整像素网格'
|
||||
}
|
||||
icon={
|
||||
isPersistingAssetKind || isPerfectPixelProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Grid2X2 className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
|
||||
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
|
||||
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
|
||||
// 与相邻的拆分图集按钮保持同一套门禁。
|
||||
disabled={
|
||||
isPersistingAssetKind ||
|
||||
isPerfectPixelProcessing ||
|
||||
isPerfectPixelPendingConfirmation
|
||||
}
|
||||
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
|
||||
onClick={() => onPerfectPixel(selectedLayer)}
|
||||
>
|
||||
<span>
|
||||
{isPersistingAssetKind
|
||||
? '保存中'
|
||||
: isPerfectPixelProcessing
|
||||
? '处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '待确认'
|
||||
: '完美像素'}
|
||||
</span>
|
||||
</PlatformIconButton>
|
||||
</>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'icon-spritesheet' ? (
|
||||
|
||||
@@ -79,6 +79,8 @@ export type ImageCanvasStageViewProps = {
|
||||
generationComposerStyle: CSSProperties | null;
|
||||
selectedToolbarStyle: CSSProperties | null;
|
||||
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
|
||||
perfectPixelLayerIds?: ReadonlySet<string>;
|
||||
pendingPerfectPixelLayerIds?: ReadonlySet<string>;
|
||||
persistingAssetKindLayerIds?: ReadonlySet<string>;
|
||||
uploadDropTarget: 'canvas' | 'assets' | null;
|
||||
contextMenu: CanvasContextMenuState | null;
|
||||
@@ -149,6 +151,7 @@ export type ImageCanvasStageViewProps = {
|
||||
onOpenRedrawPanel: (layer: CanvasLayer) => void;
|
||||
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
|
||||
onRemoveBackground: (layer: CanvasLayer) => void;
|
||||
onPerfectPixel: (layer: CanvasLayer) => void;
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
|
||||
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
|
||||
onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool | null) => void;
|
||||
@@ -236,6 +239,8 @@ export function ImageCanvasStageView({
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
|
||||
perfectPixelLayerIds = EMPTY_LAYER_ID_SET,
|
||||
pendingPerfectPixelLayerIds = EMPTY_LAYER_ID_SET,
|
||||
persistingAssetKindLayerIds = EMPTY_LAYER_ID_SET,
|
||||
uploadDropTarget,
|
||||
contextMenu,
|
||||
@@ -285,6 +290,7 @@ export function ImageCanvasStageView({
|
||||
onOpenRedrawPanel,
|
||||
onOpenCropExpandPanel,
|
||||
onRemoveBackground,
|
||||
onPerfectPixel,
|
||||
onSplitIconSpritesheet,
|
||||
onExtractUiDesignAssets,
|
||||
onUiAssetExtractionToolChange,
|
||||
@@ -409,10 +415,18 @@ export function ImageCanvasStageView({
|
||||
isPersistingAssetKind={Boolean(
|
||||
selectedLayer && persistingAssetKindLayerIds.has(selectedLayer.id),
|
||||
)}
|
||||
isPerfectPixelProcessing={Boolean(
|
||||
selectedLayer && perfectPixelLayerIds.has(selectedLayer.id),
|
||||
)}
|
||||
isPerfectPixelPendingConfirmation={Boolean(
|
||||
selectedLayer &&
|
||||
pendingPerfectPixelLayerIds.has(selectedLayer.id),
|
||||
)}
|
||||
onOpenQuickEditPanel={onOpenQuickEditPanel}
|
||||
onOpenRedrawPanel={onOpenRedrawPanel}
|
||||
onOpenCropExpandPanel={onOpenCropExpandPanel}
|
||||
onRemoveBackground={onRemoveBackground}
|
||||
onPerfectPixel={onPerfectPixel}
|
||||
onSplitIconSpritesheet={onSplitIconSpritesheet}
|
||||
onExtractUiDesignAssets={onExtractUiDesignAssets}
|
||||
onOpenCharacterAnimationPanel={onOpenCharacterAnimationPanel}
|
||||
|
||||
@@ -226,7 +226,8 @@ describe('ImageCanvasWorldView', () => {
|
||||
useResolvedAssetReadUrlMock.mockImplementation(
|
||||
(_source: string, options?: { objectKey?: string | null }) => ({
|
||||
resolvedUrl:
|
||||
options?.objectKey === 'generated-character-drafts/editor/uploaded.png'
|
||||
options?.objectKey ===
|
||||
'generated-character-drafts/editor/uploaded.png'
|
||||
? 'https://oss.example.com/uploaded.png?signature=1'
|
||||
: _source,
|
||||
isResolving: false,
|
||||
@@ -345,7 +346,9 @@ describe('ImageCanvasWorldView', () => {
|
||||
expect(screen.getByText('生成中')).toBeTruthy();
|
||||
|
||||
const world = document.querySelector('.image-canvas-editor__world');
|
||||
const marquee = world?.querySelector('.image-canvas-editor__canvas-marquee');
|
||||
const marquee = world?.querySelector(
|
||||
'.image-canvas-editor__canvas-marquee',
|
||||
);
|
||||
|
||||
expect(world?.getAttribute('style')).toContain(
|
||||
'transform: translate(10px, 20px) scale(1.5)',
|
||||
@@ -378,7 +381,11 @@ describe('ImageCanvasWorldView', () => {
|
||||
expect(within(frame).getByText('Icon Generator')).toBeTruthy();
|
||||
expect(within(frame).getByText('图标')).toBeTruthy();
|
||||
expect(within(frame).getByText('1024 x 768')).toBeTruthy();
|
||||
expect(within(frame).getByRole('status').textContent).toBe('生成中');
|
||||
const generatingStatus = within(frame).getByRole('status');
|
||||
expect(generatingStatus.textContent).toBe('生成中');
|
||||
expect(generatingStatus.className).not.toContain(
|
||||
'image-canvas-editor__generation-frame-progress--pending-confirmation',
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(frame);
|
||||
fireEvent.doubleClick(frame);
|
||||
@@ -394,6 +401,30 @@ describe('ImageCanvasWorldView', () => {
|
||||
expect(screen.queryByText('dialog-without-placeholder')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a pending perfect-pixel placeholder visible with a confirmation verdict', () => {
|
||||
const dialog = createGenerationDialog({
|
||||
id: 'dialog-perfect-pixel-pending',
|
||||
status: 'pending-confirmation',
|
||||
});
|
||||
|
||||
renderWorldView({
|
||||
canvasGenerationDialogs: [dialog],
|
||||
generateDialog: null,
|
||||
});
|
||||
|
||||
const frame = screen.getByRole('button', { name: '图像生成占位图' });
|
||||
expect(frame.className).toContain(
|
||||
'image-canvas-editor__generation-frame--pending-confirmation',
|
||||
);
|
||||
expect(within(frame).getByText('Image Generator')).toBeTruthy();
|
||||
const pendingStatus = within(frame).getByRole('status');
|
||||
expect(pendingStatus.textContent).toBe('结果待确认');
|
||||
expect(pendingStatus.className).toContain(
|
||||
'image-canvas-editor__generation-frame-progress--pending-confirmation',
|
||||
);
|
||||
expect(within(frame).queryByText('生成中')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders crop-expand frame handles and forwards drag actions', () => {
|
||||
const layer = createLayer();
|
||||
const { props } = renderWorldView({
|
||||
@@ -530,7 +561,14 @@ describe('ImageCanvasWorldView', () => {
|
||||
},
|
||||
])(
|
||||
'renders $mode generation placeholder with its own icon and badge',
|
||||
({ mode, ariaLabel, generatorLabel, badgeLabel, frameClassName, iconClassName }) => {
|
||||
({
|
||||
mode,
|
||||
ariaLabel,
|
||||
generatorLabel,
|
||||
badgeLabel,
|
||||
frameClassName,
|
||||
iconClassName,
|
||||
}) => {
|
||||
const dialog = createGenerationDialog({
|
||||
id: `dialog-${mode}`,
|
||||
mode: mode as CanvasGenerationDialogState['mode'],
|
||||
@@ -614,9 +652,9 @@ describe('ImageCanvasWorldView', () => {
|
||||
const inverseScale = '4';
|
||||
|
||||
expect(
|
||||
(within(frame).getByText(generatorLabel) as HTMLElement).style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
(
|
||||
within(frame).getByText(generatorLabel) as HTMLElement
|
||||
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
mode === 'audio-sound-effect' || mode === 'audio-background-music'
|
||||
@@ -630,9 +668,9 @@ describe('ImageCanvasWorldView', () => {
|
||||
: inverseScale,
|
||||
);
|
||||
expect(
|
||||
(within(frame).getByText(badgeLabel) as HTMLElement).style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
(
|
||||
within(frame).getByText(badgeLabel) as HTMLElement
|
||||
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
||||
).toBe(inverseScale);
|
||||
},
|
||||
);
|
||||
@@ -718,14 +756,14 @@ describe('ImageCanvasWorldView', () => {
|
||||
const inverseScale = '4';
|
||||
|
||||
expect(
|
||||
(within(layerButton).getByText('角色') as HTMLElement).style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
(
|
||||
within(layerButton).getByText('角色') as HTMLElement
|
||||
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
(within(layerButton).getByText('640 x 480 px') as HTMLElement).style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
(
|
||||
within(layerButton).getByText('640 x 480 px') as HTMLElement
|
||||
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
within(layerButton)
|
||||
@@ -733,14 +771,14 @@ describe('ImageCanvasWorldView', () => {
|
||||
.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
(within(frame).getByText('Image Generator') as HTMLElement).style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
(
|
||||
within(frame).getByText('Image Generator') as HTMLElement
|
||||
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
(within(frame).getByText('1024 x 768') as HTMLElement).style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
(
|
||||
within(frame).getByText('1024 x 768') as HTMLElement
|
||||
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
||||
).toBe(inverseScale);
|
||||
});
|
||||
|
||||
@@ -755,8 +793,11 @@ describe('ImageCanvasWorldView', () => {
|
||||
|
||||
const frame = screen.getByRole('button', { name: '图像生成占位图' });
|
||||
expect(
|
||||
(frame.querySelector('.image-canvas-editor__generation-frame-icon') as HTMLElement)
|
||||
.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
||||
(
|
||||
frame.querySelector(
|
||||
'.image-canvas-editor__generation-frame-icon',
|
||||
) as HTMLElement
|
||||
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
||||
).toBe('4');
|
||||
});
|
||||
|
||||
@@ -813,9 +854,9 @@ describe('ImageCanvasWorldView', () => {
|
||||
name: '查看角色主图图片信息',
|
||||
});
|
||||
|
||||
expect(metadataButton.querySelector('svg')?.getAttribute('class')).toContain(
|
||||
'lucide-info',
|
||||
);
|
||||
expect(
|
||||
metadataButton.querySelector('svg')?.getAttribute('class'),
|
||||
).toContain('lucide-info');
|
||||
});
|
||||
|
||||
it('renders audio layers with an audio control card instead of an image', () => {
|
||||
@@ -849,7 +890,8 @@ describe('ImageCanvasWorldView', () => {
|
||||
within(layerButton).getAllByRole('button', { name: /播放|暂停/u }),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
within(layerButton).getByRole('button', { name: '播放游戏音效' })
|
||||
within(layerButton)
|
||||
.getByRole('button', { name: '播放游戏音效' })
|
||||
.querySelector('svg')
|
||||
?.getAttribute('class'),
|
||||
).toContain('lucide-volume-2');
|
||||
@@ -872,9 +914,7 @@ describe('ImageCanvasWorldView', () => {
|
||||
).toBeTruthy();
|
||||
expect(within(layerButton).getByLabelText('静音游戏音效')).toBeTruthy();
|
||||
expect(within(layerButton).getByLabelText('调整游戏音效音量')).toBeTruthy();
|
||||
expect(
|
||||
within(layerButton).queryByText('420 x 120 px'),
|
||||
).toBeNull();
|
||||
expect(within(layerButton).queryByText('420 x 120 px')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps audio controls interactive and switches icon, play and pause states', () => {
|
||||
@@ -895,7 +935,9 @@ describe('ImageCanvasWorldView', () => {
|
||||
selectedLayerIds: [layer.id],
|
||||
});
|
||||
|
||||
const layerButton = screen.getByRole('button', { name: '选择游戏背景音乐' });
|
||||
const layerButton = screen.getByRole('button', {
|
||||
name: '选择游戏背景音乐',
|
||||
});
|
||||
const audio = within(layerButton).getByLabelText('画布音频:游戏背景音乐');
|
||||
const playbackButton = within(layerButton).getByRole('button', {
|
||||
name: '播放游戏背景音乐',
|
||||
@@ -932,9 +974,8 @@ describe('ImageCanvasWorldView', () => {
|
||||
?.getAttribute('class'),
|
||||
).toContain('lucide-play');
|
||||
|
||||
const hoveredAudio = within(hoveredLayerButton).getByLabelText(
|
||||
'画布音频:游戏背景音乐',
|
||||
);
|
||||
const hoveredAudio =
|
||||
within(hoveredLayerButton).getByLabelText('画布音频:游戏背景音乐');
|
||||
fireEvent.play(hoveredAudio);
|
||||
expect(
|
||||
within(hoveredLayerButton)
|
||||
@@ -1032,9 +1073,7 @@ describe('ImageCanvasWorldView', () => {
|
||||
expect(screen.getByRole('menu', { name: '选择素材标签' })).toBeTruthy();
|
||||
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(
|
||||
screen.queryByRole('menu', { name: '选择素材标签' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('menu', { name: '选择素材标签' })).toBeNull();
|
||||
} finally {
|
||||
document.removeEventListener('wheel', documentWheel);
|
||||
}
|
||||
@@ -1100,7 +1139,9 @@ describe('ImageCanvasWorldView', () => {
|
||||
layerButton.querySelector('.image-canvas-editor__media-preview--video')
|
||||
?.className,
|
||||
).toContain('image-canvas-editor__media-preview--variant-');
|
||||
expect(within(layerButton).queryByAltText('画布视频:生成视频 7')).toBeNull();
|
||||
expect(
|
||||
within(layerButton).queryByAltText('画布视频:生成视频 7'),
|
||||
).toBeNull();
|
||||
expect(within(layerButton).getByText('视频')).toBeTruthy();
|
||||
expect(props.onLayerPointerDown).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
@@ -1201,8 +1242,7 @@ describe('ImageCanvasWorldView', () => {
|
||||
it('keeps the last loaded sequence frame visible while the next frame is resolving', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
useResolvedAssetReadUrlMock.mockImplementation(
|
||||
(source: string) => ({
|
||||
useResolvedAssetReadUrlMock.mockImplementation((source: string) => ({
|
||||
resolvedUrl:
|
||||
source === '/generated-character-drafts/editor/frame01.png'
|
||||
? 'https://oss.example.com/frame01.png?signature=1'
|
||||
@@ -1210,8 +1250,7 @@ describe('ImageCanvasWorldView', () => {
|
||||
isResolving:
|
||||
source === '/generated-character-drafts/editor/frame02.png',
|
||||
shouldResolve: true,
|
||||
}),
|
||||
);
|
||||
}));
|
||||
const layer = createLayer({
|
||||
title: '角色动作',
|
||||
src: '/generated-character-drafts/editor/frame01.png',
|
||||
@@ -1240,9 +1279,8 @@ describe('ImageCanvasWorldView', () => {
|
||||
renderWorldView({ layers: [layer] });
|
||||
|
||||
const layerButton = screen.getByRole('button', { name: '选择角色动作' });
|
||||
const firstFrame = within(layerButton).getByAltText(
|
||||
'画布序列帧:角色动作',
|
||||
);
|
||||
const firstFrame =
|
||||
within(layerButton).getByAltText('画布序列帧:角色动作');
|
||||
|
||||
fireEvent.load(firstFrame);
|
||||
expect(
|
||||
@@ -1292,8 +1330,6 @@ describe('ImageCanvasWorldView', () => {
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: '选择生成图片' })).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '图像生成占位图' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '图像生成占位图' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,9 +316,7 @@ function selectMediaLayerWithoutDrag(
|
||||
onSelectLayer();
|
||||
}
|
||||
|
||||
function handleLayerKeyboardActivation(
|
||||
event: ReactKeyboardEvent<HTMLElement>,
|
||||
) {
|
||||
function handleLayerKeyboardActivation(event: ReactKeyboardEvent<HTMLElement>) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
@@ -364,7 +362,9 @@ function getAudioLayerIcon(layer: CanvasLayer) {
|
||||
);
|
||||
}
|
||||
|
||||
function getAudioDurationSeconds(durationSeconds: CanvasLayer['durationSeconds']) {
|
||||
function getAudioDurationSeconds(
|
||||
durationSeconds: CanvasLayer['durationSeconds'],
|
||||
) {
|
||||
return typeof durationSeconds === 'number' &&
|
||||
Number.isFinite(durationSeconds) &&
|
||||
durationSeconds > 0
|
||||
@@ -485,7 +485,9 @@ function ImageCanvasAudioLayerCard({
|
||||
event.stopPropagation();
|
||||
void togglePlayback();
|
||||
}}
|
||||
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
|
||||
onPointerDown={(event) =>
|
||||
selectMediaLayerWithoutDrag(event, onSelectLayer)
|
||||
}
|
||||
>
|
||||
{showPlaybackButton ? (
|
||||
isPlaying ? (
|
||||
@@ -504,7 +506,9 @@ function ImageCanvasAudioLayerCard({
|
||||
className="image-canvas-editor__audio-card-controls image-canvas-editor__audio-card-controls--integrated"
|
||||
onClick={stopMediaEventPropagation}
|
||||
onKeyDown={stopMediaControlKeyPropagation}
|
||||
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
|
||||
onPointerDown={(event) =>
|
||||
selectMediaLayerWithoutDrag(event, onSelectLayer)
|
||||
}
|
||||
>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
@@ -513,7 +517,9 @@ function ImageCanvasAudioLayerCard({
|
||||
preload="metadata"
|
||||
aria-label={`画布音频:${layer.title}`}
|
||||
onClick={stopMediaEventPropagation}
|
||||
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
|
||||
onPointerDown={(event) =>
|
||||
selectMediaLayerWithoutDrag(event, onSelectLayer)
|
||||
}
|
||||
onLoadedMetadata={(event) => {
|
||||
const nextDuration = event.currentTarget.duration;
|
||||
setDuration(
|
||||
@@ -522,7 +528,9 @@ function ImageCanvasAudioLayerCard({
|
||||
: fallbackDuration,
|
||||
);
|
||||
}}
|
||||
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
||||
onTimeUpdate={(event) =>
|
||||
setCurrentTime(event.currentTarget.currentTime)
|
||||
}
|
||||
onVolumeChange={(event) => {
|
||||
setVolume(event.currentTarget.volume);
|
||||
setIsMuted(event.currentTarget.muted);
|
||||
@@ -556,7 +564,9 @@ function ImageCanvasAudioLayerCard({
|
||||
event.stopPropagation();
|
||||
toggleMute();
|
||||
}}
|
||||
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
|
||||
onPointerDown={(event) =>
|
||||
selectMediaLayerWithoutDrag(event, onSelectLayer)
|
||||
}
|
||||
>
|
||||
{isMuted ? (
|
||||
<VolumeX className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
@@ -590,9 +600,12 @@ function ImageCanvasVideoLayer({
|
||||
objectKey: layer.objectKey,
|
||||
refreshKey: layer.taskId ?? layer.resourceId,
|
||||
});
|
||||
const { resolvedUrl: posterUrl } = useResolvedAssetReadUrl(layer.thumbnailSrc, {
|
||||
const { resolvedUrl: posterUrl } = useResolvedAssetReadUrl(
|
||||
layer.thumbnailSrc,
|
||||
{
|
||||
refreshKey: `${layer.taskId ?? layer.resourceId}:poster`,
|
||||
});
|
||||
},
|
||||
);
|
||||
const [showGeneratedPreview, setShowGeneratedPreview] = useState(
|
||||
!layer.thumbnailSrc,
|
||||
);
|
||||
@@ -1242,7 +1255,10 @@ export function ImageCanvasWorldView({
|
||||
getCanvasGenerationSelectionId(dialog.id),
|
||||
);
|
||||
const showFocusedChrome =
|
||||
isFocused || isSelected || dialog.status === 'generating';
|
||||
isFocused ||
|
||||
isSelected ||
|
||||
dialog.status === 'generating' ||
|
||||
dialog.status === 'pending-confirmation';
|
||||
const placeholderMeta = getGenerationPlaceholderMeta(dialog);
|
||||
|
||||
return (
|
||||
@@ -1254,6 +1270,10 @@ export function ImageCanvasWorldView({
|
||||
dialog.status === 'generating'
|
||||
? 'image-canvas-editor__generation-frame--generating'
|
||||
: ''
|
||||
} ${
|
||||
dialog.status === 'pending-confirmation'
|
||||
? 'image-canvas-editor__generation-frame--pending-confirmation'
|
||||
: ''
|
||||
} ${
|
||||
isFocused || isSelected
|
||||
? 'image-canvas-editor__generation-frame--focused'
|
||||
@@ -1321,6 +1341,14 @@ export function ImageCanvasWorldView({
|
||||
生成中
|
||||
</span>
|
||||
) : null}
|
||||
{dialog.status === 'pending-confirmation' ? (
|
||||
<span
|
||||
className="image-canvas-editor__generation-frame-progress image-canvas-editor__generation-frame-progress--pending-confirmation"
|
||||
role="status"
|
||||
>
|
||||
结果待确认
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
forgetPerfectPixelOperation,
|
||||
PERFECT_PIXEL_OPERATION_RETENTION_LIMIT,
|
||||
PERFECT_PIXEL_OPERATION_RETENTION_MS,
|
||||
readPerfectPixelOperations,
|
||||
savePerfectPixelOperation,
|
||||
} from './perfectPixelOperationStore';
|
||||
|
||||
const OWNER_USER_ID = 'user-a';
|
||||
const PROJECT_ID = 'project-1';
|
||||
const NOW = 1_785_715_270_000;
|
||||
|
||||
function buildOperation(
|
||||
dialogId: string,
|
||||
overrides: Partial<PerfectPixelOperationSnapshot> = {},
|
||||
): PerfectPixelOperationSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
kind: 'perfect-pixel',
|
||||
operationId: dialogId,
|
||||
taskId: `pixel-art-snap-${dialogId}`,
|
||||
request: {
|
||||
sourceImageSrc: 'ref:project-resource:resource-source',
|
||||
projectId: PROJECT_ID,
|
||||
sourceResourceId: 'resource-source',
|
||||
assetKind: 'character',
|
||||
assetLabel: '角色 · 完美像素',
|
||||
canvasCompletion: {
|
||||
dialogId,
|
||||
title: '角色 · 完美像素',
|
||||
placeholder: {
|
||||
x: 100,
|
||||
y: 120,
|
||||
width: 320,
|
||||
height: 320,
|
||||
originalWidth: 640,
|
||||
originalHeight: 640,
|
||||
},
|
||||
},
|
||||
},
|
||||
submittedAt: NOW,
|
||||
reconcileUntil: NOW + 75_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('perfectPixelOperationStore', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('round-trips an operation for the same owner and project', () => {
|
||||
const operation = buildOperation('dialog-1');
|
||||
savePerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, operation);
|
||||
|
||||
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
|
||||
|
||||
expect(ledger.get('dialog-1')).toEqual(operation);
|
||||
});
|
||||
|
||||
it('keeps ledgers separated by project and drops another owner entirely', () => {
|
||||
savePerfectPixelOperation(
|
||||
OWNER_USER_ID,
|
||||
PROJECT_ID,
|
||||
buildOperation('dialog-1'),
|
||||
);
|
||||
|
||||
expect(readPerfectPixelOperations(OWNER_USER_ID, 'project-2').size).toBe(0);
|
||||
// 中文注释:同一台机器换账号后,上一个账号的请求(含源图直传地址)必须整条丢弃。
|
||||
expect(readPerfectPixelOperations('user-b', PROJECT_ID).size).toBe(0);
|
||||
expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses to store an operation whose request targets another project', () => {
|
||||
savePerfectPixelOperation(
|
||||
OWNER_USER_ID,
|
||||
'project-2',
|
||||
buildOperation('dialog-1'),
|
||||
);
|
||||
|
||||
expect(readPerfectPixelOperations(OWNER_USER_ID, 'project-2').size).toBe(0);
|
||||
});
|
||||
|
||||
it('fails closed on a tampered ledger entry instead of replaying it', () => {
|
||||
savePerfectPixelOperation(
|
||||
OWNER_USER_ID,
|
||||
PROJECT_ID,
|
||||
buildOperation('dialog-1'),
|
||||
);
|
||||
savePerfectPixelOperation(
|
||||
OWNER_USER_ID,
|
||||
PROJECT_ID,
|
||||
buildOperation('dialog-2'),
|
||||
);
|
||||
const key = `genarrative.imageCanvas.perfectPixelOperations.${PROJECT_ID}`;
|
||||
const stored = JSON.parse(window.localStorage.getItem(key)!) as {
|
||||
ownerUserId: string;
|
||||
operations: Record<string, PerfectPixelOperationSnapshot>;
|
||||
};
|
||||
stored.operations['dialog-1']!.taskId = 'pixel-art-snap-somewhere-else';
|
||||
window.localStorage.setItem(key, JSON.stringify(stored));
|
||||
|
||||
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
|
||||
|
||||
expect(ledger.has('dialog-1')).toBe(false);
|
||||
expect(ledger.has('dialog-2')).toBe(true);
|
||||
});
|
||||
|
||||
it('drops entries past the retention window', () => {
|
||||
savePerfectPixelOperation(
|
||||
OWNER_USER_ID,
|
||||
PROJECT_ID,
|
||||
buildOperation('dialog-old', {
|
||||
submittedAt: NOW - PERFECT_PIXEL_OPERATION_RETENTION_MS - 1,
|
||||
reconcileUntil: NOW - PERFECT_PIXEL_OPERATION_RETENTION_MS + 74_999,
|
||||
}),
|
||||
);
|
||||
savePerfectPixelOperation(
|
||||
OWNER_USER_ID,
|
||||
PROJECT_ID,
|
||||
buildOperation('dialog-fresh'),
|
||||
);
|
||||
|
||||
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
|
||||
|
||||
expect([...ledger.keys()]).toEqual(['dialog-fresh']);
|
||||
});
|
||||
|
||||
it('caps the ledger size by keeping the newest submissions', () => {
|
||||
for (
|
||||
let index = 0;
|
||||
index <= PERFECT_PIXEL_OPERATION_RETENTION_LIMIT;
|
||||
index += 1
|
||||
) {
|
||||
savePerfectPixelOperation(
|
||||
OWNER_USER_ID,
|
||||
PROJECT_ID,
|
||||
buildOperation(`dialog-${index}`, {
|
||||
submittedAt: NOW - (PERFECT_PIXEL_OPERATION_RETENTION_LIMIT - index),
|
||||
reconcileUntil:
|
||||
NOW - (PERFECT_PIXEL_OPERATION_RETENTION_LIMIT - index) + 75_000,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
|
||||
|
||||
expect(ledger.size).toBe(PERFECT_PIXEL_OPERATION_RETENTION_LIMIT);
|
||||
expect(ledger.has('dialog-0')).toBe(false);
|
||||
expect(
|
||||
ledger.has(`dialog-${PERFECT_PIXEL_OPERATION_RETENTION_LIMIT}`),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('forgets a settled operation and clears the key once empty', () => {
|
||||
savePerfectPixelOperation(
|
||||
OWNER_USER_ID,
|
||||
PROJECT_ID,
|
||||
buildOperation('dialog-1'),
|
||||
);
|
||||
|
||||
forgetPerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, 'dialog-1');
|
||||
|
||||
expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(0);
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
`genarrative.imageCanvas.perfectPixelOperations.${PROJECT_ID}`,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('degrades to an empty ledger without throwing when storage is unavailable', () => {
|
||||
const setItem = vi
|
||||
.spyOn(Storage.prototype, 'setItem')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('QuotaExceededError');
|
||||
});
|
||||
const getItem = vi
|
||||
.spyOn(Storage.prototype, 'getItem')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('SecurityError');
|
||||
});
|
||||
|
||||
try {
|
||||
// 中文注释:隐私模式 / 配额写满时账本读写都会抛。这里必须静默降级——账本缺失只
|
||||
// 意味着刷新后不能自动收口,绝不能反过来阻断发起、重试或删除。
|
||||
expect(() =>
|
||||
savePerfectPixelOperation(
|
||||
OWNER_USER_ID,
|
||||
PROJECT_ID,
|
||||
buildOperation('dialog-1'),
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
forgetPerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, 'dialog-1'),
|
||||
).not.toThrow();
|
||||
expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
setItem.mockRestore();
|
||||
getItem.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns an empty ledger without an owner or project', () => {
|
||||
savePerfectPixelOperation(
|
||||
OWNER_USER_ID,
|
||||
PROJECT_ID,
|
||||
buildOperation('dialog-1'),
|
||||
);
|
||||
|
||||
expect(readPerfectPixelOperations(null, PROJECT_ID).size).toBe(0);
|
||||
expect(readPerfectPixelOperations(OWNER_USER_ID, ' ').size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { hydratePerfectPixelOperation } from './ImageCanvasEditorModel';
|
||||
import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes';
|
||||
|
||||
/**
|
||||
* 中文注释:完美像素操作账本的本机存储。
|
||||
*
|
||||
* **这是明确设计,不是降级方案**:账本记录的是「本机这次会话发出过哪一次 POST」,
|
||||
* 它是对账凭据,不是用户的画布内容,因此不进项目布局。由此得到两条硬性质:
|
||||
*
|
||||
* 1. **写入同步、不依赖网络、不依赖服务端校验。** 发 POST 前先落本机账本即可获得
|
||||
* 「请求可被追溯」的保证,不必再用严格布局保存去换同一个保证。布局校验(例如
|
||||
* 资源元数据读写不对称)从此不可能阻断完美像素的发起或重试。
|
||||
* 2. **账本缺失只降级、绝不阻断。** 换设备、换浏览器、清缓存、隐私模式、配额写满,
|
||||
* 都会读不到账本。那种情况下占位收口为可删除的失败态,用户可以删掉重来;
|
||||
* 任何路径都不得因为「读不到账本」而拒绝用户发起、重试或删除。
|
||||
*
|
||||
* 代价是跨设备不再自动收口:在 A 机发起、到 B 机打开同一项目时,B 机看到的是失败占位
|
||||
* 而不是对账中的占位。完美像素是免费同步操作,重做成本极低,用这点换掉「用户数据里
|
||||
* 混着系统对账状态」的耦合是划算的。
|
||||
*/
|
||||
|
||||
const PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX =
|
||||
'genarrative.imageCanvas.perfectPixelOperations';
|
||||
|
||||
/**
|
||||
* 中文注释:账本保留期。对账窗口只有 75 秒,但 `pending-confirmation` 占位允许用户在很久
|
||||
* 之后手动重试同一次 operation,那条路径同样需要账本,所以保留期必须远长于对账窗口。
|
||||
*/
|
||||
export const PERFECT_PIXEL_OPERATION_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
|
||||
|
||||
/**
|
||||
* 中文注释:单个项目最多保留的账本条数,超出时丢弃最旧的。防止长期使用把 localStorage
|
||||
* 配额吃满——配额写满会连带影响同域下其它本地缓存,而账本本身是可丢弃的。
|
||||
*/
|
||||
export const PERFECT_PIXEL_OPERATION_RETENTION_LIMIT = 32;
|
||||
|
||||
type PerfectPixelOperationLedger = Map<string, PerfectPixelOperationSnapshot>;
|
||||
|
||||
function getPerfectPixelOperationStorage() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function perfectPixelOperationStorageKey(projectId: string | null | undefined) {
|
||||
const normalizedProjectId = projectId?.trim();
|
||||
if (!normalizedProjectId) {
|
||||
return null;
|
||||
}
|
||||
return `${PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX}.${normalizedProjectId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文注释:读账本时同时校验归属。同一台机器可能先后登录不同账号,账本里带着上一个
|
||||
* 账号的请求(含源图直传地址),换人后必须整条丢弃而不是原样返回。
|
||||
*/
|
||||
function readLedgerEntries(
|
||||
storage: Storage,
|
||||
key: string,
|
||||
ownerUserId: string,
|
||||
): PerfectPixelOperationLedger {
|
||||
const ledger: PerfectPixelOperationLedger = new Map();
|
||||
const rawValue = storage.getItem(key);
|
||||
if (!rawValue) {
|
||||
return ledger;
|
||||
}
|
||||
const parsedValue: unknown = JSON.parse(rawValue);
|
||||
if (!parsedValue || typeof parsedValue !== 'object') {
|
||||
return ledger;
|
||||
}
|
||||
const record = parsedValue as Record<string, unknown>;
|
||||
const recordOwnerUserId =
|
||||
typeof record.ownerUserId === 'string' ? record.ownerUserId.trim() : '';
|
||||
if (recordOwnerUserId !== ownerUserId) {
|
||||
storage.removeItem(key);
|
||||
return ledger;
|
||||
}
|
||||
const operations = record.operations;
|
||||
if (!operations || typeof operations !== 'object') {
|
||||
return ledger;
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const [operationId, value] of Object.entries(
|
||||
operations as Record<string, unknown>,
|
||||
)) {
|
||||
// 中文注释:本机账本与布局快照走同一套 v1 白名单校验。存储可被用户或其它脚本改写,
|
||||
// 任何字段漂移都必须失败关闭——绝不能据一份可疑账本重放 POST。
|
||||
const operation = hydratePerfectPixelOperation(value, operationId);
|
||||
if (!operation) {
|
||||
continue;
|
||||
}
|
||||
if (now - operation.submittedAt > PERFECT_PIXEL_OPERATION_RETENTION_MS) {
|
||||
continue;
|
||||
}
|
||||
ledger.set(operationId, operation);
|
||||
}
|
||||
return ledger;
|
||||
}
|
||||
|
||||
function writeLedgerEntries(
|
||||
storage: Storage,
|
||||
key: string,
|
||||
ownerUserId: string,
|
||||
ledger: PerfectPixelOperationLedger,
|
||||
) {
|
||||
if (ledger.size === 0) {
|
||||
storage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
const retained = [...ledger.values()]
|
||||
.sort((left, right) => right.submittedAt - left.submittedAt)
|
||||
.slice(0, PERFECT_PIXEL_OPERATION_RETENTION_LIMIT);
|
||||
storage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
ownerUserId,
|
||||
operations: Object.fromEntries(
|
||||
retained.map((operation) => [operation.operationId, operation]),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文注释:读取某项目在本机的全部有效账本。任何异常都返回空账本——读不到账本只意味着
|
||||
* 「刷新后不能自动收口」,调用方必须能在空账本下继续工作。
|
||||
*/
|
||||
export function readPerfectPixelOperations(
|
||||
currentUserId: string | null | undefined,
|
||||
projectId: string | null | undefined,
|
||||
): PerfectPixelOperationLedger {
|
||||
const ownerUserId = currentUserId?.trim();
|
||||
const key = perfectPixelOperationStorageKey(projectId);
|
||||
const storage = getPerfectPixelOperationStorage();
|
||||
if (!ownerUserId || !key || !storage) {
|
||||
return new Map();
|
||||
}
|
||||
try {
|
||||
return readLedgerEntries(storage, key, ownerUserId);
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文注释:写入一条账本。必须在发 POST 之前调用——这是整条链路里唯一「请求已发出」的
|
||||
* 本地证据。写入失败(配额、隐私模式)同样不阻断:调用方照常发 POST,只是丢掉刷新后
|
||||
* 自动收口的能力。
|
||||
*/
|
||||
export function savePerfectPixelOperation(
|
||||
currentUserId: string | null | undefined,
|
||||
projectId: string | null | undefined,
|
||||
operation: PerfectPixelOperationSnapshot,
|
||||
) {
|
||||
const ownerUserId = currentUserId?.trim();
|
||||
const key = perfectPixelOperationStorageKey(projectId);
|
||||
const storage = getPerfectPixelOperationStorage();
|
||||
if (!ownerUserId || !key || !storage) {
|
||||
return;
|
||||
}
|
||||
if (operation.request.projectId !== projectId?.trim()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ledger = readLedgerEntries(storage, key, ownerUserId);
|
||||
ledger.set(operation.operationId, operation);
|
||||
writeLedgerEntries(storage, key, ownerUserId, ledger);
|
||||
} catch {
|
||||
// 中文注释:账本是尽力而为的本机便利,写失败不得影响本次提交。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文注释:操作收口(结果已套用 / 只落素材库 / 快照判定失效)后清账本,避免过期条目
|
||||
* 在下次加载时再发一次无谓的对账 GET。
|
||||
*/
|
||||
export function forgetPerfectPixelOperation(
|
||||
currentUserId: string | null | undefined,
|
||||
projectId: string | null | undefined,
|
||||
operationId: string,
|
||||
) {
|
||||
const ownerUserId = currentUserId?.trim();
|
||||
const key = perfectPixelOperationStorageKey(projectId);
|
||||
const storage = getPerfectPixelOperationStorage();
|
||||
const normalizedOperationId = operationId.trim();
|
||||
if (!ownerUserId || !key || !storage || !normalizedOperationId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ledger = readLedgerEntries(storage, key, ownerUserId);
|
||||
if (!ledger.delete(normalizedOperationId)) {
|
||||
return;
|
||||
}
|
||||
writeLedgerEntries(storage, key, ownerUserId, ledger);
|
||||
} catch {
|
||||
// 中文注释:清理失败最多留下一条过期账本,保留期会兜底。
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act,renderHook } from '@testing-library/react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import {
|
||||
requiresGenerationDeleteConfirmation,
|
||||
useCanvasGenerationDialogs,
|
||||
} from './useCanvasGenerationDialogs';
|
||||
|
||||
function createDialog(
|
||||
mode: CanvasGenerationDialogState['mode'],
|
||||
@@ -26,6 +29,48 @@ function createDialog(
|
||||
};
|
||||
}
|
||||
|
||||
function durablePerfectPixelDialog(
|
||||
dialogId: string,
|
||||
status: 'generating' | 'pending-confirmation' | 'failed',
|
||||
): CanvasGenerationDialogState {
|
||||
const submittedAt = 1_700_000_000_000;
|
||||
return {
|
||||
id: dialogId,
|
||||
mode: 'quick-edit',
|
||||
prompt: '完美像素',
|
||||
status,
|
||||
composerOpen: true,
|
||||
sourceLayerId: 'layer-source',
|
||||
// 中文注释:marker 从占位创建那一刻就存在,账本形成后也一直在。夹具必须还原这个形状,
|
||||
// 否则下游用例是在测一个真实链路里不存在的状态。
|
||||
perfectPixelOperationId: dialogId,
|
||||
perfectPixelOperation: {
|
||||
version: 1,
|
||||
kind: 'perfect-pixel',
|
||||
operationId: dialogId,
|
||||
taskId: `pixel-art-snap-${dialogId}`,
|
||||
request: {
|
||||
sourceImageSrc: 'generated-images/editor/source.png',
|
||||
projectId: 'project-1',
|
||||
canvasCompletion: {
|
||||
dialogId,
|
||||
title: '源图 · 完美像素',
|
||||
placeholder: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
},
|
||||
},
|
||||
},
|
||||
submittedAt,
|
||||
reconcileUntil: submittedAt + 75_000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('useCanvasGenerationDialogs', () => {
|
||||
it('archives, activates, updates, and removes canvas generation dialogs', () => {
|
||||
const onActivate = vi.fn();
|
||||
@@ -346,4 +391,116 @@ describe('useCanvasGenerationDialogs', () => {
|
||||
result.current.getCanvasGenerationDialogById(firstDialogId),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns a newly opened explicit dialog from the synchronous snapshot getter in the same action', () => {
|
||||
const { result } = renderHook(() => useCanvasGenerationDialogs());
|
||||
let openedDialogId = '';
|
||||
let immediateSnapshot: CanvasGenerationDialogState[] = [];
|
||||
|
||||
act(() => {
|
||||
openedDialogId = result.current.openCanvasGenerationDialog({
|
||||
...createDialog('quick-edit', '完美像素'),
|
||||
id: 'generation-dialog-perfect-pixel',
|
||||
status: 'generating',
|
||||
});
|
||||
immediateSnapshot = result.current.getCanvasGenerationDialogsSnapshot();
|
||||
});
|
||||
|
||||
expect(openedDialogId).toBe('generation-dialog-perfect-pixel');
|
||||
expect(immediateSnapshot).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'generation-dialog-perfect-pixel',
|
||||
mode: 'quick-edit',
|
||||
prompt: '完美像素',
|
||||
status: 'generating',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
// 中文注释:占位是用户文档的一部分,删除它不撤销任何在途请求——完美像素没有取消接口,
|
||||
// 结果照常落库并进素材库。低层删除因此不得对未收口 operation 抗命,否则上层会写出
|
||||
// 「历史记了一笔、占位还在」的伪历史。
|
||||
it.each(['generating', 'pending-confirmation', 'failed'] as const)(
|
||||
'deletes an unsettled durable perfect-pixel operation by id while %s',
|
||||
(status) => {
|
||||
const { result } = renderHook(() => useCanvasGenerationDialogs());
|
||||
const dialogId = 'perfect-pixel-durable';
|
||||
|
||||
act(() => {
|
||||
result.current.restoreCanvasGenerationDialogs([
|
||||
durablePerfectPixelDialog(dialogId, status),
|
||||
]);
|
||||
});
|
||||
act(() => {
|
||||
result.current.removeCanvasGenerationDialogById(dialogId);
|
||||
});
|
||||
|
||||
expect(result.current.activeCanvasGenerationDialog).toBeNull();
|
||||
expect(result.current.canvasGenerationDialogs).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
// 中文注释:确认弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立;完美像素免费且删除
|
||||
// 占位不撤销在途请求,所以它在任何状态都直接删。
|
||||
it.each(['generating', 'pending-confirmation', 'failed'] as const)(
|
||||
'never asks for delete confirmation on a perfect-pixel placeholder while %s',
|
||||
(status) => {
|
||||
expect(
|
||||
requiresGenerationDeleteConfirmation(
|
||||
durablePerfectPixelDialog('perfect-pixel-confirm', status),
|
||||
),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('never asks for delete confirmation before the perfect-pixel ledger exists', () => {
|
||||
// 中文注释:源图解析 / 直传最长 90 秒,期间占位只有 marker、还没有账本。此前判据按账本
|
||||
// 判,用户在这段窗口里删一个免费操作会看到「已消耗的泥点不会返还」。
|
||||
expect(
|
||||
requiresGenerationDeleteConfirmation({
|
||||
id: 'perfect-pixel-preparing',
|
||||
mode: 'quick-edit',
|
||||
prompt: '完美像素',
|
||||
status: 'generating',
|
||||
composerOpen: false,
|
||||
sourceLayerId: 'layer-source',
|
||||
requiresLiveSession: true,
|
||||
perfectPixelOperationId: 'perfect-pixel-preparing',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('still asks for delete confirmation on an ordinary generating placeholder', () => {
|
||||
expect(
|
||||
requiresGenerationDeleteConfirmation({
|
||||
id: 'generation-dialog-1',
|
||||
...createDialog('generate', '一只猫'),
|
||||
status: 'generating',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requiresGenerationDeleteConfirmation({
|
||||
id: 'generation-dialog-2',
|
||||
...createDialog('generate', '一只猫'),
|
||||
status: 'failed',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('drops unsettled durable perfect-pixel operations together with their source layer', () => {
|
||||
const { result } = renderHook(() => useCanvasGenerationDialogs());
|
||||
const dialogId = 'perfect-pixel-durable';
|
||||
|
||||
act(() => {
|
||||
result.current.restoreCanvasGenerationDialogs([
|
||||
durablePerfectPixelDialog(dialogId, 'pending-confirmation'),
|
||||
]);
|
||||
});
|
||||
act(() => {
|
||||
result.current.removeCanvasGenerationDialogsByLayerId('layer-source');
|
||||
});
|
||||
|
||||
expect(result.current.activeCanvasGenerationDialog).toBeNull();
|
||||
expect(result.current.canvasGenerationDialogs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,35 @@ type CanvasGenerationDialogUpdater = (
|
||||
dialog: CanvasGenerationDialogState,
|
||||
) => CanvasGenerationDialogState | null;
|
||||
|
||||
export type CanvasGenerationDialogDraft = Omit<
|
||||
CanvasGenerationDialogState,
|
||||
'id'
|
||||
> & {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 中文注释:删除生成占位前是否需要二次确认。
|
||||
*
|
||||
* 确认弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立。完美像素
|
||||
* `generation_cost_mud_points = 0`,删除占位也不撤销任何在途请求——它没有取消接口,结果照常
|
||||
* 落库并进素材库,服务端 completion 发现 dialog 已不在会返回 DialogMissing 并由客户端提示。
|
||||
* 所以完美像素占位在任何状态都直接删,不额外解释。
|
||||
*
|
||||
* 判据必须看 `perfectPixelOperationId` 而**不是** `perfectPixelOperation`:后者要到源图解析 /
|
||||
* 直传完成后才写入,那一段预算最长 90 秒(未登记的本地图片要走 ticket → PUT → confirm),
|
||||
* 期间占位是 `generating` 且没有账本,按账本判会让用户删一个免费操作时看到「已消耗的泥点
|
||||
* 不会返还」。marker 在占位创建那一刻就写上,覆盖完整生命周期。
|
||||
*
|
||||
* 更一般地:这里问的是「**这次生成计不计费**」,账本的有无只是它在某一段时间内的代理。
|
||||
* 用短寿命字段的存在性去判断长期属性,正是本仓库反复出错的形状。
|
||||
*/
|
||||
export function requiresGenerationDeleteConfirmation(
|
||||
dialog: CanvasGenerationDialogState,
|
||||
) {
|
||||
return dialog.status === 'generating' && !dialog.perfectPixelOperationId;
|
||||
}
|
||||
|
||||
function withGenerationTimestamps<T extends GenerateDialogState | null>(
|
||||
nextDialog: T,
|
||||
previousDialog?: GenerateDialogState | null,
|
||||
@@ -90,6 +119,14 @@ export function useCanvasGenerationDialogs({
|
||||
[activeCanvasGenerationDialog, inactiveGenerateDialogs],
|
||||
);
|
||||
|
||||
const getCanvasGenerationDialogsSnapshot = useCallback(() => {
|
||||
const currentDialog = generateDialogRef.current;
|
||||
return [
|
||||
...inactiveGenerateDialogsRef.current,
|
||||
...(isCanvasGenerationDialog(currentDialog) ? [currentDialog] : []),
|
||||
];
|
||||
}, []);
|
||||
|
||||
const createGenerationDialogId = useCallback(() => {
|
||||
generationDialogCounterRef.current += 1;
|
||||
return `generation-dialog-${generationDialogCounterRef.current}`;
|
||||
@@ -116,7 +153,7 @@ export function useCanvasGenerationDialogs({
|
||||
}, []);
|
||||
|
||||
const openCanvasGenerationDialog = useCallback(
|
||||
(dialog: Omit<CanvasGenerationDialogState, 'id'>) => {
|
||||
(dialog: CanvasGenerationDialogDraft) => {
|
||||
const currentDialog = generateDialogRef.current;
|
||||
if (isCanvasGenerationDialog(currentDialog)) {
|
||||
inactiveGenerateDialogsRef.current =
|
||||
@@ -133,16 +170,30 @@ export function useCanvasGenerationDialogs({
|
||||
];
|
||||
}
|
||||
archiveActiveCanvasGenerationDialog();
|
||||
const id = createGenerationDialogId();
|
||||
const requestedId = dialog.id?.trim();
|
||||
const requestedIdAlreadyExists =
|
||||
Boolean(requestedId) &&
|
||||
getCanvasGenerationDialogsSnapshot().some(
|
||||
(existingDialog) => existingDialog.id === requestedId,
|
||||
);
|
||||
const id =
|
||||
requestedId && !requestedIdAlreadyExists
|
||||
? requestedId
|
||||
: createGenerationDialogId();
|
||||
const { id: _requestedId, ...dialogWithoutId } = dialog;
|
||||
const nextDialog = withGenerationTimestamps({
|
||||
...dialog,
|
||||
...dialogWithoutId,
|
||||
id,
|
||||
});
|
||||
generateDialogRef.current = nextDialog;
|
||||
setGenerateDialogState(nextDialog);
|
||||
return id;
|
||||
},
|
||||
[archiveActiveCanvasGenerationDialog, createGenerationDialogId],
|
||||
[
|
||||
archiveActiveCanvasGenerationDialog,
|
||||
createGenerationDialogId,
|
||||
getCanvasGenerationDialogsSnapshot,
|
||||
],
|
||||
);
|
||||
|
||||
const updateCanvasGenerationDialogById = useCallback(
|
||||
@@ -187,6 +238,10 @@ export function useCanvasGenerationDialogs({
|
||||
[],
|
||||
);
|
||||
|
||||
// 中文注释:低层删除不再对未收口的完美像素 operation 抗命。删除占位不撤销任何在途请求
|
||||
// ——完美像素没有取消接口,结果照常落库并进素材库,服务端 completion 发现 dialog 已不在
|
||||
// 会返回 DialogMissing,客户端有对应提示。封锁换来的只是「结果自动回填画布」这一便利,
|
||||
// 代价却是用户画布上出现删不掉的对象;低层偷偷保留还会让上层写出伪历史。
|
||||
const removeCanvasGenerationDialogById = useCallback(
|
||||
(dialogId: string) => {
|
||||
updateCanvasGenerationDialogById(dialogId, () => null);
|
||||
@@ -255,7 +310,9 @@ export function useCanvasGenerationDialogs({
|
||||
nextCounter,
|
||||
);
|
||||
const activeDialog =
|
||||
[...dialogs].reverse().find((dialog) => dialog.composerOpen !== false) ??
|
||||
[...dialogs]
|
||||
.reverse()
|
||||
.find((dialog) => dialog.composerOpen !== false) ??
|
||||
dialogs[dialogs.length - 1] ??
|
||||
null;
|
||||
const nextActiveDialog = activeDialog
|
||||
@@ -331,6 +388,7 @@ export function useCanvasGenerationDialogs({
|
||||
inactiveGenerateDialogsRef,
|
||||
activeCanvasGenerationDialog,
|
||||
canvasGenerationDialogs,
|
||||
getCanvasGenerationDialogsSnapshot,
|
||||
archiveActiveCanvasGenerationDialog,
|
||||
openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
|
||||
@@ -36,6 +36,7 @@ const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
|
||||
const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() =>
|
||||
vi.fn(),
|
||||
);
|
||||
const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
|
||||
@@ -74,6 +75,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
});
|
||||
|
||||
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock,
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
}));
|
||||
|
||||
@@ -565,6 +567,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
beforeEach(() => {
|
||||
resolveEditorImageReferenceDataUrlMock.mockReset();
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock.mockReset();
|
||||
uploadEditorMediaAssetObjectFileMock.mockReset();
|
||||
uploadEditorMediaAssetFileMock.mockReset();
|
||||
editEditorImageMock.mockReset();
|
||||
extractEditorUiDesignAssetsMock.mockReset();
|
||||
@@ -583,8 +586,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock.mockImplementation(
|
||||
async (src: string) => src,
|
||||
);
|
||||
uploadEditorMediaAssetFileMock.mockResolvedValue({
|
||||
src: 'https://signed.example.test/generation-reference.png',
|
||||
uploadEditorMediaAssetObjectFileMock.mockResolvedValue({
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
assetObjectId: 'asset-object-generation-reference',
|
||||
@@ -615,16 +617,56 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
dateNowSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(2);
|
||||
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(2);
|
||||
const [firstFile, , firstOptions] =
|
||||
uploadEditorMediaAssetFileMock.mock.calls[0] ?? [];
|
||||
uploadEditorMediaAssetObjectFileMock.mock.calls[0] ?? [];
|
||||
const [secondFile, , secondOptions] =
|
||||
uploadEditorMediaAssetFileMock.mock.calls[1] ?? [];
|
||||
uploadEditorMediaAssetObjectFileMock.mock.calls[1] ?? [];
|
||||
expect((firstFile as File).name).not.toBe((secondFile as File).name);
|
||||
expect(firstOptions.pathSegments).not.toEqual(secondOptions.pathSegments);
|
||||
});
|
||||
|
||||
it('reuses a caller supplied upload id for the same inline operation', async () => {
|
||||
const options = { uploadId: 'perfect-pixel-operation-1' };
|
||||
|
||||
await resolveEditorGenerationMediaReference(
|
||||
{ src: 'data:image/png;base64,YQ==' },
|
||||
'image',
|
||||
'project-1',
|
||||
options,
|
||||
);
|
||||
await resolveEditorGenerationMediaReference(
|
||||
{ src: 'data:image/png;base64,YQ==' },
|
||||
'image',
|
||||
'project-1',
|
||||
options,
|
||||
);
|
||||
|
||||
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(2);
|
||||
for (const [
|
||||
file,
|
||||
mediaType,
|
||||
uploadOptions,
|
||||
] of uploadEditorMediaAssetObjectFileMock.mock.calls) {
|
||||
expect((file as File).name).toBe(
|
||||
'generation-reference-perfect-pixel-operation-1.png',
|
||||
);
|
||||
expect(mediaType).toBe('image');
|
||||
expect(uploadOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
pathSegments: [
|
||||
'editor',
|
||||
'generation-references',
|
||||
'project-1',
|
||||
'perfect-pixel-operation-1',
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('uploads image references without an object reference before generation', async () => {
|
||||
const controller = new AbortController();
|
||||
resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce(
|
||||
'data:image/png;base64,ZXhhbXBsZQ==',
|
||||
);
|
||||
@@ -632,23 +674,124 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
{ src: '/creation-type-references/example.webp' },
|
||||
'image',
|
||||
'project-1',
|
||||
{
|
||||
signal: controller.signal,
|
||||
uploadId: 'perfect-pixel-operation-1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith(
|
||||
'/creation-type-references/example.webp',
|
||||
controller.signal,
|
||||
);
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
'image',
|
||||
expect.objectContaining({
|
||||
assetKind: 'editor_generation_reference_image',
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
|
||||
expect(result).toBe(
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the abort signal through blob fetch and object registration', async () => {
|
||||
const controller = new AbortController();
|
||||
const blobMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Blob(['video'], { type: 'video/mp4' }));
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
blob: blobMock,
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
try {
|
||||
await resolveEditorGenerationMediaReference(
|
||||
{ src: 'blob:https://editor.example.test/reference-video' },
|
||||
'video',
|
||||
'project-1',
|
||||
{
|
||||
signal: controller.signal,
|
||||
uploadId: 'video-operation-1',
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'blob:https://editor.example.test/reference-video',
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(blobMock).toHaveBeenCalledTimes(1);
|
||||
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
'video',
|
||||
expect.objectContaining({
|
||||
pathSegments: [
|
||||
'editor',
|
||||
'generation-references',
|
||||
'project-1',
|
||||
'video-operation-1',
|
||||
],
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not parse or upload an inline Data URL after cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new DOMException('已取消', 'AbortError'));
|
||||
|
||||
await expect(
|
||||
resolveEditorGenerationMediaReference(
|
||||
{ src: 'data:image/png;base64,YQ==' },
|
||||
'image',
|
||||
'project-1',
|
||||
{
|
||||
signal: controller.signal,
|
||||
uploadId: 'cancelled-operation',
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
|
||||
expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops after image source parsing when cancellation wins the boundary', async () => {
|
||||
const controller = new AbortController();
|
||||
let finishImageParsing!: (value: string) => void;
|
||||
resolveEditorImageReferenceDataUrlMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
finishImageParsing = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const resolution = resolveEditorGenerationMediaReference(
|
||||
{ src: '/creation-type-references/slow.webp' },
|
||||
'image',
|
||||
'project-1',
|
||||
{
|
||||
signal: controller.signal,
|
||||
uploadId: 'cancelled-after-parse',
|
||||
},
|
||||
);
|
||||
controller.abort(new DOMException('已取消', 'AbortError'));
|
||||
finishImageParsing('data:image/png;base64,YQ==');
|
||||
|
||||
await expect(resolution).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith(
|
||||
'/creation-type-references/slow.webp',
|
||||
controller.signal,
|
||||
);
|
||||
expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits quick edits and updates the source layer directly', async () => {
|
||||
editEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
@@ -803,9 +946,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({ assetKind: 'icon-spritesheet' }),
|
||||
]}
|
||||
initialLayers={[createLayer({ assetKind: 'icon-spritesheet' })]}
|
||||
initialQuickEditPanel={{
|
||||
mode: 'quick-edit',
|
||||
sourceLayerId: 'layer-source',
|
||||
@@ -1051,7 +1192,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
'image',
|
||||
expect.objectContaining({
|
||||
@@ -1067,7 +1208,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
expect(
|
||||
uploadEditorMediaAssetFileMock.mock.invocationCallOrder[0],
|
||||
uploadEditorMediaAssetObjectFileMock.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(editEditorImageMock.mock.invocationCallOrder[0] ?? 0);
|
||||
expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
'referenceImageSrcs',
|
||||
@@ -1191,8 +1332,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
prompt: '角色换成蓝色披风',
|
||||
warning: {
|
||||
code: 'postprocess-failed-source-preserved',
|
||||
reason:
|
||||
'生成任务成功,后处理失败。',
|
||||
reason: '生成任务成功,后处理失败。',
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -1392,7 +1532,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
'video',
|
||||
expect.objectContaining({
|
||||
@@ -2616,7 +2756,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
|
||||
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
|
||||
expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes the wallet and shows the warning after a queued character generation completes', async () => {
|
||||
@@ -2640,8 +2780,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
prompt: '队列角色生成',
|
||||
}),
|
||||
queueState: createQueueState({
|
||||
warning:
|
||||
'生成任务成功,后处理失败。',
|
||||
warning: '生成任务成功,后处理失败。',
|
||||
}),
|
||||
});
|
||||
render(
|
||||
@@ -2689,7 +2828,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reloads a queued project snapshot again when the completion dialog is still unresolved', async () => {
|
||||
it('reloads a queued project snapshot when a later duplicate completion dialog is unresolved', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
@@ -2700,6 +2839,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
title: '队列项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'generation-dialog-background-removal-completed',
|
||||
resourceId: 'generation-dialog-background-removal-completed',
|
||||
itemType: 'generation-dialog',
|
||||
dialog: {
|
||||
id: 'dialog-background-removal',
|
||||
status: 'idle',
|
||||
generatedLayerId: 'layer-background-removal-result',
|
||||
},
|
||||
},
|
||||
{
|
||||
layerId: 'generation-dialog-background-removal',
|
||||
resourceId: 'generation-dialog-background-removal',
|
||||
@@ -2777,8 +2926,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
await applyQueuedEditorGenerationProject(
|
||||
{
|
||||
queueState: createQueueState({
|
||||
warning:
|
||||
'生成任务成功,后处理失败。',
|
||||
warning: '生成任务成功,后处理失败。',
|
||||
}),
|
||||
},
|
||||
'editor-project-1',
|
||||
@@ -3053,7 +3201,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
|
||||
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
|
||||
expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits icon spec objects without requiring an icon spec reference', async () => {
|
||||
@@ -3435,8 +3583,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
taskId: 'task-ui-assets',
|
||||
warning: {
|
||||
code: 'postprocess-failed-source-preserved',
|
||||
reason:
|
||||
'生成任务成功,后处理失败。',
|
||||
reason: '生成任务成功,后处理失败。',
|
||||
},
|
||||
});
|
||||
render(
|
||||
@@ -3614,8 +3761,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
height: 768,
|
||||
warning: {
|
||||
code: 'postprocess-failed-source-preserved',
|
||||
reason:
|
||||
'生成任务成功,后处理失败。',
|
||||
reason: '生成任务成功,后处理失败。',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { getExternalGenerationJobStatus } from '../../services/external-generati
|
||||
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
|
||||
import {
|
||||
type EditorMediaAssetUploadType,
|
||||
uploadEditorMediaAssetFile,
|
||||
uploadEditorMediaAssetObjectFile,
|
||||
} from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
@@ -31,6 +31,10 @@ import {
|
||||
generateEditorVideo,
|
||||
loadEditorProject,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import {
|
||||
findCanvasGenerationDialogRecords,
|
||||
isUnresolvedCanvasGenerationDialogRecord,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasGenerationInputs,
|
||||
@@ -113,6 +117,12 @@ type EditorGenerationMediaReference = {
|
||||
type EditorGenerationMediaReferenceOptions = {
|
||||
allowRegisteredIds?: boolean;
|
||||
requireImageObjectReference?: boolean;
|
||||
// 中文注释:需要 unknown 重放的同步操作由调用方传入稳定 id;普通入口不传时仍为每次
|
||||
// 上传生成随机路径,避免并发参考图互相覆盖。
|
||||
uploadId?: string;
|
||||
// 中文注释:由调用方的阶段预算驱动,并贯穿源读取、Data URL 转换以及
|
||||
// ticket → PUT → confirm,不能只在最外层停止 await。
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
let editorGenerationUploadFallbackCounter = 0;
|
||||
@@ -150,7 +160,16 @@ function resolveEditorGenerationMediaReferenceSource(
|
||||
);
|
||||
}
|
||||
|
||||
function dataUrlToEditorGenerationFile(dataUrl: string, fileName: string) {
|
||||
function throwIfEditorGenerationMediaUploadAborted(signal?: AbortSignal) {
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
|
||||
function dataUrlToEditorGenerationFile(
|
||||
dataUrl: string,
|
||||
fileName: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
throwIfEditorGenerationMediaUploadAborted(signal);
|
||||
const [header = '', payload = ''] = dataUrl.split(',');
|
||||
const mimeMatch = /^data:([^;]+)(;base64)?$/iu.exec(header);
|
||||
if (!mimeMatch) {
|
||||
@@ -158,10 +177,12 @@ function dataUrlToEditorGenerationFile(dataUrl: string, fileName: string) {
|
||||
}
|
||||
const type = mimeMatch[1] ?? 'image/png';
|
||||
const binary = mimeMatch[2] ? atob(payload) : decodeURIComponent(payload);
|
||||
throwIfEditorGenerationMediaUploadAborted(signal);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
throwIfEditorGenerationMediaUploadAborted(signal);
|
||||
return new File([bytes], fileName, { type });
|
||||
}
|
||||
|
||||
@@ -169,18 +190,22 @@ async function inlineMediaSourceToEditorGenerationFile(
|
||||
source: string,
|
||||
mediaType: EditorMediaAssetUploadType,
|
||||
uploadId: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
throwIfEditorGenerationMediaUploadAborted(signal);
|
||||
const fileName = `generation-reference-${uploadId}.${
|
||||
mediaType === 'video' ? 'mp4' : mediaType === 'audio' ? 'mp3' : 'png'
|
||||
}`;
|
||||
if (/^data:/iu.test(source)) {
|
||||
return dataUrlToEditorGenerationFile(source, fileName);
|
||||
return dataUrlToEditorGenerationFile(source, fileName, signal);
|
||||
}
|
||||
const response = await fetch(source);
|
||||
const response = await fetch(source, { signal });
|
||||
throwIfEditorGenerationMediaUploadAborted(signal);
|
||||
if (!response.ok) {
|
||||
throw new Error('读取本地生成参考素材失败');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
throwIfEditorGenerationMediaUploadAborted(signal);
|
||||
return new File([blob], fileName, {
|
||||
type: blob.type || `${mediaType}/*`,
|
||||
});
|
||||
@@ -190,11 +215,19 @@ async function uploadEditorGenerationInlineMediaSource(
|
||||
source: string,
|
||||
mediaType: EditorMediaAssetUploadType,
|
||||
projectId?: string | null,
|
||||
signal?: AbortSignal,
|
||||
stableUploadId?: string | null,
|
||||
) {
|
||||
const normalizedProjectId = projectId?.trim() || 'unscoped';
|
||||
const uploadId = createEditorGenerationMediaUploadId();
|
||||
const uploaded = await uploadEditorMediaAssetFile(
|
||||
await inlineMediaSourceToEditorGenerationFile(source, mediaType, uploadId),
|
||||
const uploadId =
|
||||
stableUploadId?.trim() || createEditorGenerationMediaUploadId();
|
||||
const uploaded = await uploadEditorMediaAssetObjectFile(
|
||||
await inlineMediaSourceToEditorGenerationFile(
|
||||
source,
|
||||
mediaType,
|
||||
uploadId,
|
||||
signal,
|
||||
),
|
||||
mediaType,
|
||||
{
|
||||
assetKind: `editor_generation_reference_${mediaType}`,
|
||||
@@ -205,6 +238,7 @@ async function uploadEditorGenerationInlineMediaSource(
|
||||
uploadId,
|
||||
],
|
||||
entityId: normalizedProjectId,
|
||||
signal,
|
||||
...(projectId?.trim()
|
||||
? { metadata: { editor_project_id: projectId.trim() } }
|
||||
: {}),
|
||||
@@ -219,6 +253,7 @@ export async function resolveEditorGenerationMediaReference(
|
||||
projectId?: string | null,
|
||||
options: EditorGenerationMediaReferenceOptions = {},
|
||||
) {
|
||||
throwIfEditorGenerationMediaUploadAborted(options.signal);
|
||||
const resourceId = reference.resourceId?.trim();
|
||||
const hasRegisteredReference =
|
||||
options.allowRegisteredIds !== false &&
|
||||
@@ -239,13 +274,17 @@ export async function resolveEditorGenerationMediaReference(
|
||||
if (!inlineSource && !imageSourceRequiresUpload) {
|
||||
return source;
|
||||
}
|
||||
const uploadSource = imageSourceRequiresUpload && !inlineSource
|
||||
? await resolveEditorImageReferenceDataUrl(source)
|
||||
: source;
|
||||
const uploadSource =
|
||||
imageSourceRequiresUpload && !inlineSource
|
||||
? await resolveEditorImageReferenceDataUrl(source, options.signal)
|
||||
: source;
|
||||
throwIfEditorGenerationMediaUploadAborted(options.signal);
|
||||
return uploadEditorGenerationInlineMediaSource(
|
||||
uploadSource,
|
||||
mediaType,
|
||||
projectId,
|
||||
options.signal,
|
||||
options.uploadId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -426,25 +465,9 @@ function projectHasUnresolvedGenerationDialog(
|
||||
project: EditorProjectSnapshot,
|
||||
dialogId: string | null | undefined,
|
||||
) {
|
||||
const normalizedDialogId = dialogId?.trim();
|
||||
if (!normalizedDialogId) {
|
||||
return false;
|
||||
}
|
||||
return project.layers.some((item) => {
|
||||
if (item.itemType !== 'generation-dialog') {
|
||||
return false;
|
||||
}
|
||||
const dialog =
|
||||
item.dialog && typeof item.dialog === 'object'
|
||||
? (item.dialog as Record<string, unknown>)
|
||||
: null;
|
||||
return (
|
||||
dialog?.id === normalizedDialogId &&
|
||||
(dialog.status === 'generating' ||
|
||||
typeof dialog.generatedLayerId !== 'string' ||
|
||||
dialog.generatedLayerId.trim() === '')
|
||||
);
|
||||
});
|
||||
return findCanvasGenerationDialogRecords(project, dialogId).some(
|
||||
isUnresolvedCanvasGenerationDialogRecord,
|
||||
);
|
||||
}
|
||||
|
||||
function notifyWalletBalanceMayHaveChanged(callback?: () => void) {
|
||||
|
||||
@@ -152,6 +152,7 @@ function GenerationSurfaceHarness() {
|
||||
activeCanvasGenerationDialog: activeCanvasDialog,
|
||||
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
|
||||
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
|
||||
activateCanvasGenerationDialog: dialogs.activateCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
||||
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
|
||||
getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById,
|
||||
|
||||
@@ -74,6 +74,9 @@ type ImageCanvasGenerationSurfaceOptions = {
|
||||
openCanvasGenerationDialog: (
|
||||
dialog: Omit<CanvasGenerationDialogState, 'id'>,
|
||||
) => string;
|
||||
activateCanvasGenerationDialog: (
|
||||
targetDialog: CanvasGenerationDialogState,
|
||||
) => void;
|
||||
updateCanvasGenerationDialogById: (
|
||||
dialogId: string,
|
||||
updater: CanvasGenerationDialogUpdater,
|
||||
@@ -109,6 +112,13 @@ type ImageCanvasGenerationSurfaceOptions = {
|
||||
project: EditorProjectSnapshot,
|
||||
action?: CanvasHistoryAction,
|
||||
) => void;
|
||||
applyProjectSnapshotWithoutHistory?: (
|
||||
project: EditorProjectSnapshot,
|
||||
) => boolean | void;
|
||||
flushProjectPersistence?: (options?: {
|
||||
preferLatestGenerationDialogs?: boolean;
|
||||
}) => Promise<void>;
|
||||
refreshAssetLibrary?: () => Promise<unknown> | void;
|
||||
onWalletBalanceMayHaveChanged?: () => void;
|
||||
};
|
||||
|
||||
@@ -164,6 +174,7 @@ export function useImageCanvasGenerationSurface({
|
||||
activeCanvasGenerationDialog,
|
||||
canvasGenerationDialogs,
|
||||
openCanvasGenerationDialog,
|
||||
activateCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
hasCanvasGenerationDialogById,
|
||||
getCanvasGenerationDialogById,
|
||||
@@ -186,6 +197,9 @@ export function useImageCanvasGenerationSurface({
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot,
|
||||
applyProjectSnapshotWithoutHistory,
|
||||
flushProjectPersistence,
|
||||
refreshAssetLibrary,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
}: ImageCanvasGenerationSurfaceOptions) {
|
||||
const toolbarOptionCloseTimerRef = useRef<ReturnType<
|
||||
@@ -202,6 +216,7 @@ export function useImageCanvasGenerationSurface({
|
||||
generateDialog,
|
||||
setGenerateDialog,
|
||||
openCanvasGenerationDialog,
|
||||
activateCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
hasCanvasGenerationDialogById,
|
||||
getCanvasGenerationDialogById,
|
||||
@@ -223,6 +238,9 @@ export function useImageCanvasGenerationSurface({
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot,
|
||||
applyProjectSnapshotWithoutHistory,
|
||||
flushProjectPersistence,
|
||||
refreshAssetLibrary,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
});
|
||||
|
||||
@@ -486,6 +504,9 @@ export function useImageCanvasGenerationSurface({
|
||||
onSubmitCharacterAnimation={() =>
|
||||
void generationWorkflow.submitCharacterAnimation()
|
||||
}
|
||||
onRetryPerfectPixelOperation={(dialogId) =>
|
||||
void generationWorkflow.retryPerfectPixelOperation(dialogId)
|
||||
}
|
||||
onUpdateSpecFormValue={generationWorkflow.updateSpecFormValue}
|
||||
onUpdateIconDescriptionText={
|
||||
generationWorkflow.updateIconDescriptionsText
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user