合并:同步最新master
Project CI / Repository checks (pull_request) Failing after 44s
Project CI / Frontend tests (pull_request) Failing after 2m51s
Project CI / Backend tests (pull_request) Successful in 5m29s
Project CI / Native shell tests (pull_request) Successful in 13m32s

合并 origin/master 最新提交 7b075df39
保留当前分支的 BGM 优化与共享音频面板方案

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	docs/project-memory/shared-memory/pitfalls.md
#	server-rs/crates/api-server/src/editor_generation_queue.rs
#	src/components/image-editor/useImageCanvasGenerationSurface.tsx
#	src/services/image-editor/editorProjectClient.ts
This commit is contained in:
2026-08-06 03:10:30 +00:00
90 changed files with 12219 additions and 916 deletions
@@ -54,6 +54,7 @@ type ImageCanvasBasicGenerationComposerViewProps = {
onPickReferenceFromCanvas?: () => void;
onToggleReferenceMenu?: () => void;
onRememberImageModel?: (model: string) => void;
hasPendingImageReferenceUploads?: boolean;
onSubmit: (dialog: GenerateDialogState) => void;
dialogLabel?: string;
includeDimensions?: boolean;
@@ -107,6 +108,7 @@ export function ImageCanvasBasicGenerationComposerView({
onPickReferenceFromCanvas,
onToggleReferenceMenu,
onRememberImageModel = () => {},
hasPendingImageReferenceUploads = false,
onSubmit,
dialogLabel,
includeDimensions = true,
@@ -271,6 +273,7 @@ export function ImageCanvasBasicGenerationComposerView({
includeDimensions={shouldIncludeDimensions}
includeModel={includeModel}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
dimensionSizeAriaLabelPrefix={dimensionSizeAriaLabelPrefix}
optionLabelPrefix={resolvedOptionLabelPrefix}
@@ -44,6 +44,7 @@ type ImageCanvasCharacterGenerationComposerViewProps = {
onOpenSpecDialog: (specType: SpecGenerationType) => void;
onRequestUpload: (target: UploadTarget) => void;
onRememberImageModel: (model: string) => void;
hasPendingImageReferenceUploads?: boolean;
onSubmit: (dialog: GenerateDialogState) => void;
};
@@ -97,6 +98,7 @@ export function ImageCanvasCharacterGenerationComposerView({
onOpenSpecDialog,
onRequestUpload,
onRememberImageModel,
hasPendingImageReferenceUploads = false,
onSubmit,
}: ImageCanvasCharacterGenerationComposerViewProps) {
useImageCanvasFloatingOptionDismiss({
@@ -289,6 +291,7 @@ export function ImageCanvasCharacterGenerationComposerView({
setGenerateDialog={setGenerateDialog}
includeDimensions
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
optionLabelPrefix="生成图片"
cost={calculateEditorImageGenerationPrice({
kind: 'character',
@@ -390,6 +390,7 @@ export type CanvasContextMenuState =
};
export type QuickEditPanelState = {
referenceUploadContextId?: string;
mode?: 'quick-edit' | 'redraw';
sourceLayerId: string;
prompt: string;
@@ -58,12 +58,17 @@ import type {
ImageContextMenuState,
QuickEditPanelState,
SidebarPanel,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
import {
applyEditorGenerationPricingConfig,
isCanvasGenerationDialog,
QUICK_EDIT_REFERENCE_LIMIT,
resolveDialogExtraImageReferenceLimit,
resolveExtraImageReferenceLimit,
UI_EXTRA_REFERENCE_LIMIT,
} from './ImageCanvasGenerationModel';
import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel';
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
@@ -97,7 +102,10 @@ import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
import { useImageCanvasStageController } from './useImageCanvasStageController';
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
import {
type ImageReferenceUploadCapacity,
useImageCanvasUploadWorkflow,
} from './useImageCanvasUploadWorkflow';
import {
DEFAULT_IMAGE_CANVAS_VIEWPORT,
useImageCanvasViewportControls,
@@ -430,6 +438,9 @@ export function ImageCanvasEditorView({
const appendUiAssetExtractionReferencesRef = useRef<
(references: CharacterReferenceImage[]) => void
>(() => {});
const resolveImageReferenceUploadCapacityRef = useRef<
(target: UploadTarget) => ImageReferenceUploadCapacity | null
>(() => null);
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>(
() => {},
);
@@ -672,6 +683,9 @@ export function ImageCanvasEditorView({
discardCanvasHistoryEntriesContainingLayerRef.current(matchesLayer),
[],
);
const canDeleteLayersRef = useRef<(targetLayerIds: string[]) => boolean>(
() => true,
);
const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({
layers,
setLayers,
@@ -679,6 +693,8 @@ export function ImageCanvasEditorView({
setSelectedLayerIds,
onDeleteLayerSideEffects: (layerId) =>
clearDeletedLayerGenerationStateRef.current(layerId),
canDeleteLayers: (targetLayerIds) =>
canDeleteLayersRef.current(targetLayerIds),
discardHistoryEntriesContainingLayer: discardAssetRelatedCanvasHistory,
});
const {
@@ -734,6 +750,14 @@ export function ImageCanvasEditorView({
onDeleteAssets: removeCanvasLayersLinkedToAssets,
});
const pendingImageReferenceUploadCountRef = useRef(0);
const isPendingReferenceUploadContextMutationLocked = useCallback(
() => pendingImageReferenceUploadCountRef.current > 0,
[],
);
const rejectPendingReferenceUploadContextMutation = useCallback(() => {
window.alert('参考图正在上传,请等待上传完成后再切换或关闭生成面板');
}, []);
const handleActivateCanvasGenerationDialog = useCallback(() => {
closeGenerationTransientStateRef.current();
setSelectedLayerId(null);
@@ -762,6 +786,8 @@ export function ImageCanvasEditorView({
getGeneratingDialogPlaceholder,
} = useCanvasGenerationDialogs({
onActivate: handleActivateCanvasGenerationDialog,
isContextMutationLocked: isPendingReferenceUploadContextMutationLocked,
onContextMutationRejected: rejectPendingReferenceUploadContextMutation,
});
canvasGenerationDialogsRef.current = canvasGenerationDialogs;
const canvasHistoryRefs = useMemo(
@@ -1239,7 +1265,7 @@ export function ImageCanvasEditorView({
captureCanvasHistory(action);
applyProjectSnapshot(project);
if (action.type !== 'perfect-pixel') {
void refreshAssetLibrary();
void refreshAssetLibrary();
}
},
[applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary],
@@ -1345,6 +1371,7 @@ export function ImageCanvasEditorView({
const {
uploadInputRef,
uploadTarget,
pendingImageReferenceUploadCount,
requestUpload,
handleUploadInputChange,
addUploadedFiles,
@@ -1366,12 +1393,17 @@ export function ImageCanvasEditorView({
setLayers,
setGenerateDialog,
setQuickEditPanel: (updater) => setQuickEditPanelRef.current(updater),
resolveImageReferenceUploadCapacity: (target) =>
resolveImageReferenceUploadCapacityRef.current(target),
appendUiAssetExtractionReferences: (references) =>
appendUiAssetExtractionReferencesRef.current(references),
appendCanvasLayersWithResources,
captureCanvasHistory,
selectSingleLayer,
refreshAssetLibrary,
});
pendingImageReferenceUploadCountRef.current =
pendingImageReferenceUploadCount;
const generationSurface = useImageCanvasGenerationSurface({
layers,
canvasSize,
@@ -1388,6 +1420,7 @@ export function ImageCanvasEditorView({
generationReferenceButtonRef,
publicationReferenceButtonRef,
iconSpecButtonRef,
hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0,
generateDialog,
setGenerateDialog,
activeCanvasGenerationDialog,
@@ -1551,6 +1584,7 @@ export function ImageCanvasEditorView({
clearDeletedLayerGenerationState,
uiAssetExtractionState,
uiAssetExtractionSourceLayer,
quickEditPanel,
quickEditSelectionState,
quickEditSelectionSourceLayer,
changeUiAssetExtractionTool,
@@ -1595,6 +1629,137 @@ export function ImageCanvasEditorView({
clearDeletedLayerGenerationState;
appendUiAssetExtractionReferencesRef.current =
appendUiAssetExtractionReferences;
resolveImageReferenceUploadCapacityRef.current = (target) => {
const imageReferenceCount = (references: CharacterReferenceImage[] = []) =>
references.filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
).length;
const generateDialogContextId = generateDialog
? (generateDialog.id ??
`${generateDialog.mode}:${generateDialog.sourceLayerId ?? 'draft'}`)
: undefined;
if (target === 'spec-reference') {
return generateDialog?.mode === 'spec'
? {
label: '规范参考图',
currentCount: 0,
limit: 1,
contextId: generateDialogContextId,
}
: null;
}
if (target === 'character-spec') {
if (generateDialog?.mode !== 'character') {
return null;
}
const referencesFit =
imageReferenceCount(generateDialog.characterReferences) <=
resolveDialogExtraImageReferenceLimit(generateDialog);
return {
label: '角色主图',
currentCount: 0,
limit: referencesFit ? 1 : 0,
contextId: generateDialogContextId,
};
}
if (target === 'icon-spec') {
if (generateDialog?.mode !== 'icon') {
return null;
}
const referencesFit =
imageReferenceCount(generateDialog.generationReferences) <=
resolveDialogExtraImageReferenceLimit(generateDialog);
return {
label: '图标规范图',
currentCount: 0,
limit: referencesFit ? 1 : 0,
contextId: generateDialogContextId,
};
}
if (target === 'ui-design-icon-spec') {
if (generateDialog?.mode !== 'ui-design') {
return null;
}
const referencesFit =
imageReferenceCount(generateDialog.generationReferences) <=
resolveDialogExtraImageReferenceLimit(generateDialog);
return {
label: 'UI设计规范图',
currentCount: 0,
limit: referencesFit ? 1 : 0,
contextId: generateDialogContextId,
};
}
if (target === 'character-reference') {
return generateDialog?.mode === 'character'
? {
label: '角色参考图',
currentCount: imageReferenceCount(
generateDialog.characterReferences,
),
limit: resolveDialogExtraImageReferenceLimit(generateDialog),
contextId: generateDialogContextId,
}
: null;
}
if (target === 'generation-reference') {
return generateDialog && generateDialog.mode !== 'video'
? {
label: '生成参考图',
currentCount: imageReferenceCount(
generateDialog.generationReferences,
),
limit: resolveDialogExtraImageReferenceLimit(generateDialog),
contextId: generateDialogContextId,
}
: null;
}
if (target === 'publication-reference') {
return generateDialog?.mode === 'publication'
? {
label: '宣发参考图',
currentCount: imageReferenceCount(
generateDialog.publicationReferences,
),
limit: resolveDialogExtraImageReferenceLimit(generateDialog),
contextId: generateDialogContextId,
}
: null;
}
if (target === 'quick-edit-reference') {
return quickEditPanel && quickEditPanel.mode !== 'redraw'
? {
label: '快速编辑参考图',
currentCount: imageReferenceCount(
quickEditPanel.quickEditReferences,
),
limit: resolveExtraImageReferenceLimit(
quickEditPanel.model,
QUICK_EDIT_REFERENCE_LIMIT,
1,
),
contextId: quickEditPanel.referenceUploadContextId,
}
: null;
}
if (target === 'ui-asset-extraction-reference') {
return uiAssetExtractionState
? {
label: 'UI素材参考图',
currentCount: imageReferenceCount(
uiAssetExtractionState.references,
),
limit: resolveExtraImageReferenceLimit(
uiAssetExtractionState.model,
UI_EXTRA_REFERENCE_LIMIT,
1,
),
contextId: uiAssetExtractionState.referenceUploadContextId,
}
: null;
}
return null;
};
useEffect(() => {
if (!isProjectReady || startupIntentConsumedRef.current) {
return;
@@ -1737,6 +1902,33 @@ export function ImageCanvasEditorView({
contextMenu?.kind === 'layer'
? (layers.find((layer) => layer.id === contextMenu.layerId) ?? null)
: null;
const canDeleteLayersDuringReferenceUpload = useCallback(
(targetLayerIds: string[]) => {
if (pendingImageReferenceUploadCountRef.current <= 0) {
return true;
}
const protectedSourceLayerIds = [
generateDialog?.sourceLayerId,
generationSurface.quickEditPanel?.sourceLayerId,
uiAssetExtractionState?.sourceLayerId,
].filter((layerId): layerId is string => Boolean(layerId));
if (
!targetLayerIds.some((layerId) =>
protectedSourceLayerIds.includes(layerId),
)
) {
return true;
}
window.alert('参考图正在上传,请等待上传完成后再删除当前生成源图');
return false;
},
[
generateDialog?.sourceLayerId,
generationSurface.quickEditPanel?.sourceLayerId,
uiAssetExtractionState?.sourceLayerId,
],
);
canDeleteLayersRef.current = canDeleteLayersDuringReferenceUpload;
const {
canvasClipboard,
canCopyContextLayers,
@@ -1781,6 +1973,8 @@ export function ImageCanvasEditorView({
onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog,
exportLayerImage,
onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning,
canDeleteLayers: (targetLayerIds) =>
canDeleteLayersRef.current(targetLayerIds),
});
const {
canvasMarquee,
@@ -2330,6 +2524,7 @@ export function ImageCanvasEditorView({
: uiAssetExtractionSourceLayer,
quickEditSelectionState,
quickEditSelectionSourceLayer,
hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0,
generationComposerStyle,
selectedToolbarStyle,
perfectPixelLayerIds,
@@ -83,6 +83,7 @@ type ImageCanvasGenerationComposerViewProps = {
isPickingCharacterReferenceFromCanvas: boolean;
isPickingIconSpecFromCanvas: boolean;
isPickingUiDesignSpecFromCanvas: boolean;
hasPendingImageReferenceUploads?: boolean;
generateDialog: GenerateDialogState | null;
backgroundMusicPromptAssist?: BackgroundMusicPromptAssistComposerController;
updateCanvasGenerationDialogById: (
@@ -1002,6 +1003,7 @@ export function ImageCanvasGenerationComposerView({
setIsPickingCharacterReferenceFromCanvas,
setIsPickingIconSpecFromCanvas,
setIsPickingUiDesignSpecFromCanvas,
hasPendingImageReferenceUploads = false,
onOpenSpecDialog,
onRequestUpload,
onSubmitImageGeneration,
@@ -1083,6 +1085,7 @@ export function ImageCanvasGenerationComposerView({
setIsGenerationReferenceMenuOpen((open) => !open)
}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
/>
) : null}
@@ -1105,6 +1108,7 @@ export function ImageCanvasGenerationComposerView({
onOpenSpecDialog={onOpenSpecDialog}
onUpdateSpecFormValue={onUpdateSpecFormValue}
onRequestUpload={onRequestUpload}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
/>
) : null}
@@ -1129,6 +1133,7 @@ export function ImageCanvasGenerationComposerView({
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
/>
) : null}
@@ -1152,6 +1157,7 @@ export function ImageCanvasGenerationComposerView({
onUpdateSpecFormValue={onUpdateSpecFormValue}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
/>
) : null}
@@ -1227,6 +1233,7 @@ export function ImageCanvasGenerationComposerView({
onOpenSpecDialog={onOpenSpecDialog}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
/>
) : null}
@@ -1248,6 +1255,7 @@ export function ImageCanvasGenerationComposerView({
onRequestUpload={onRequestUpload}
onUpdateIconDescriptionText={onUpdateIconDescriptionText}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitIconSpritesheetGeneration}
/>
) : null}
@@ -1303,6 +1311,7 @@ export function ImageCanvasGenerationComposerView({
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
setQuickEditPanel={setQuickEditPanel}
onSubmit={onSubmitQuickEdit}
/>
@@ -1134,6 +1134,93 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('caps image references before a sixth reference can enter submission state', () => {
const existingReferences = Array.from({ length: 5 }, (_, index) => ({
id: `reference-${index}`,
label: `参考图${index + 1}`,
src: `generated-character-drafts/editor/reference-${index}.png`,
}));
const imageDialog: GenerateDialogState = {
mode: 'generate',
prompt: '',
status: 'idle',
generationReferences: existingReferences,
};
expect(
appendGenerationReference(
imageDialog,
createLayer({ id: 'reference-6', title: '参考图6' }),
),
).toMatchObject({
generationReferences: existingReferences,
});
const characterDialog: GenerateDialogState = {
mode: 'character',
prompt: '',
status: 'idle',
characterSpecReference: existingReferences[0],
characterReferences: existingReferences.slice(1),
};
expect(
appendCharacterReference(
characterDialog,
createLayer({ id: 'character-reference-6', title: '角色参考图5' }),
),
).toMatchObject({
characterReferences: existingReferences.slice(1),
});
const iconDialog: GenerateDialogState = {
mode: 'icon',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
generationReferences: existingReferences.slice(0, 4),
};
expect(
appendGenerationReference(
iconDialog,
createLayer({ id: 'icon-reference-5', title: '图标参考图5' }),
),
).toMatchObject({
generationReferences: existingReferences.slice(0, 4),
});
});
it('reserves the primary-image slot before character and UI specs are selected', () => {
const references = Array.from({ length: 5 }, (_, index) => ({
id: `reference-${index}`,
label: `参考图${index + 1}`,
src: `/reference-${index}.png`,
}));
const characterDialog: GenerateDialogState = {
mode: 'character',
prompt: '',
status: 'idle',
characterReferences: references.slice(0, 4),
};
const uiDialog: GenerateDialogState = {
mode: 'ui-design',
prompt: '',
status: 'idle',
generationReferences: references.slice(0, 4),
};
expect(
appendCharacterReference(
characterDialog,
createLayer({ id: 'character-reference-5' }),
),
).toMatchObject({ characterReferences: references.slice(0, 4) });
expect(
appendGenerationReference(
uiDialog,
createLayer({ id: 'ui-reference-5' }),
),
).toMatchObject({ generationReferences: references.slice(0, 4) });
});
it('updates failed spec and icon dialog fields back to idle state', () => {
const specDialog: GenerateDialogState = {
mode: 'spec',
@@ -11,6 +11,7 @@ import type {
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import {
appendLimitedImageReferences,
appendLimitedQuickEditReferences,
AUDIO_FRAME_DISPLAY_SIZE,
AUDIO_FRAME_ORIGINAL_SIZE,
@@ -36,6 +37,7 @@ import {
normalizeEditorImageModel,
PUBLICATION_FRAME_ORIGINAL_SIZE,
resizeGenerationPlaceholderToImageSelection,
resolveDialogExtraImageReferenceLimit,
resolveEditorImageGenerationPixelSize,
resolveEditorVideoGenerationPixelSize,
SPEC_FRAME_ORIGINAL_SIZE,
@@ -644,10 +646,7 @@ function resolveGeneratedSourceDialogMode({
if (sourceLayer.assetKind === 'character-animation') {
return 'character-animation';
}
if (
sourceLayer.assetKind === 'video' ||
sourceLayer.mediaType === 'video'
) {
if (sourceLayer.assetKind === 'video' || sourceLayer.mediaType === 'video') {
return 'video';
}
if (sourceLayer.assetKind === 'sound-effect') {
@@ -1005,8 +1004,7 @@ export function createSameSourceGenerationDialogDraft({
videoModel: sourceDialog?.videoModel ?? draft.videoModel,
videoAspectRatio:
sourceDialog?.videoAspectRatio ?? draft.videoAspectRatio,
videoResolution:
sourceDialog?.videoResolution ?? draft.videoResolution,
videoResolution: sourceDialog?.videoResolution ?? draft.videoResolution,
videoDurationSeconds:
sourceDialog?.videoDurationSeconds ?? draft.videoDurationSeconds,
videoMode: sourceDialog?.videoMode ?? draft.videoMode,
@@ -1424,6 +1422,7 @@ export function assignCharacterSpecReference(
? {
...resetFailedGenerationDialog(dialog),
characterSpecReference: createCanvasLayerReference(layer),
characterReferences: dialog.characterReferences,
composerOpen: true,
}
: dialog;
@@ -1437,10 +1436,11 @@ export function appendCharacterReference(
getReferenceMediaType(layer) === 'image'
? {
...resetFailedGenerationDialog(dialog),
characterReferences: [
...(dialog.characterReferences ?? []),
createCanvasLayerReference(layer),
],
characterReferences: appendLimitedImageReferences(
dialog.characterReferences,
[createCanvasLayerReference(layer)],
resolveDialogExtraImageReferenceLimit(dialog),
),
composerOpen: true,
}
: dialog;
@@ -1474,10 +1474,11 @@ export function appendGenerationReference(
}
return {
...resetFailedGenerationDialog(dialog),
generationReferences: [
...(dialog.generationReferences ?? []),
createCanvasLayerReference(layer),
],
generationReferences: appendLimitedImageReferences(
dialog.generationReferences,
[createCanvasLayerReference(layer)],
resolveDialogExtraImageReferenceLimit(dialog),
),
composerOpen: true,
};
}
@@ -1501,10 +1502,11 @@ export function appendPublicationReference(
getReferenceMediaType(layer) === 'image'
? {
...resetFailedGenerationDialog(dialog),
publicationReferences: [
...(dialog.publicationReferences ?? []),
createCanvasLayerReference(layer),
],
publicationReferences: appendLimitedImageReferences(
dialog.publicationReferences,
[createCanvasLayerReference(layer)],
resolveDialogExtraImageReferenceLimit(dialog),
),
composerOpen: true,
}
: dialog;
@@ -1521,6 +1523,11 @@ export function assignIconSpecReference(
? {
...resetFailedGenerationDialog(dialog),
iconSpecReference: createCanvasLayerReference(layer),
generationReferences: appendLimitedImageReferences(
[],
dialog.generationReferences ?? [],
resolveDialogExtraImageReferenceLimit(dialog),
),
composerOpen: true,
}
: dialog;
@@ -1537,6 +1544,7 @@ export function assignUiDesignSpecReference(
? {
...resetFailedGenerationDialog(dialog),
uiDesignSpecReference: createCanvasLayerReference(layer),
generationReferences: dialog.generationReferences,
composerOpen: true,
}
: dialog;
@@ -15,13 +15,13 @@ import {
function ImageOptionsHarness({
initialDialog,
includeDimensions = true,
hasPendingImageReferenceUploads = false,
onRememberImageModel = vi.fn(),
lockedModel,
}: {
initialDialog: GenerateDialogState;
includeDimensions?: boolean;
hasPendingImageReferenceUploads?: boolean;
onRememberImageModel?: (model: string) => void;
lockedModel?: string;
}) {
@@ -35,6 +35,7 @@ function ImageOptionsHarness({
dialog={dialog}
setGenerateDialog={setDialog}
includeDimensions={includeDimensions}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onRememberImageModel={onRememberImageModel}
lockedModel={lockedModel}
cost={calculateEditorImageGenerationPrice({
@@ -50,6 +51,13 @@ function ImageOptionsHarness({
<output aria-label="当前风格">{dialog.style ?? '-'}</output>
<output aria-label="当前状态">{dialog.status}</output>
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
<output aria-label="当前参考图数量">
{dialog.mode === 'character'
? (dialog.characterReferences?.length ?? 0)
: dialog.mode === 'publication'
? (dialog.publicationReferences?.length ?? 0)
: (dialog.generationReferences?.length ?? 0)}
</output>
<output aria-label="当前占位">
{dialog.placeholder
? [
@@ -122,9 +130,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
/>,
);
expect(
screen.queryByRole('checkbox', { name: '像素艺术' }),
).toBeNull();
expect(screen.queryByRole('checkbox', { name: '像素艺术' })).toBeNull();
},
);
@@ -267,6 +273,90 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
expect(rememberImageModel).not.toHaveBeenCalled();
});
it('rejects a model switch that would discard existing references', () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
const rememberImageModel = vi.fn();
render(
<ImageOptionsHarness
initialDialog={{
mode: 'icon',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
generationReferences: Array.from({ length: 8 }, (_, index) => ({
id: `reference-${index + 1}`,
label: `参考图${index + 1}`,
src: `/reference-${index + 1}.png`,
})),
}}
includeDimensions={false}
onRememberImageModel={rememberImageModel}
/>,
);
expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8');
fireEvent.click(
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole(
'button',
{ name: 'gpt-image-2' },
),
);
expect(alertMock).toHaveBeenCalledWith(
'当前已有 8 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换',
);
expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8');
expect(screen.getByLabelText('当前模型').textContent).toBe(
IMAGE_MODEL_NANOBANANA2,
);
expect(rememberImageModel).not.toHaveBeenCalled();
});
it('rejects model switching while reference uploads are pending', () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
const rememberImageModel = vi.fn();
render(
<ImageOptionsHarness
initialDialog={{
mode: 'generate',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
}}
hasPendingImageReferenceUploads
onRememberImageModel={rememberImageModel}
/>,
);
fireEvent.click(
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole(
'button',
{ name: 'gpt-image-2' },
),
);
expect(alertMock).toHaveBeenCalledWith(
'参考图正在上传,请等待上传完成后再切换模型',
);
expect(screen.getByLabelText('当前模型').textContent).toBe(
IMAGE_MODEL_NANOBANANA2,
);
expect(
(
screen.getByRole('button', {
name: '生成角色形象',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(rememberImageModel).not.toHaveBeenCalled();
});
it('renders option menus and shows mud point text in the submit button', () => {
render(
<ImageOptionsHarness
@@ -14,6 +14,7 @@ import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
import {
countDialogExtraImageReferences,
EDITOR_IMAGE_DIMENSION_OPTIONS,
EDITOR_IMAGE_MODEL_OPTIONS,
getEditorImageModelDisplayName,
@@ -21,6 +22,7 @@ import {
normalizeEditorImageModel,
QUICK_EDIT_MODEL_OPTIONS,
resizeGenerationPlaceholderToImageSelection,
resolveDialogExtraImageReferenceLimit,
resolveEditorImageSizeLabel,
} from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -30,6 +32,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
includeDimensions: boolean;
includeModel?: boolean;
hasPendingImageReferenceUploads?: boolean;
onRememberImageModel: (model: string) => void;
optionLabelPrefix?: string;
dimensionRatioAriaLabelPrefix?: string;
@@ -121,6 +124,7 @@ export function ImageCanvasGenerationImageOptionsView({
setGenerateDialog,
includeDimensions,
includeModel = true,
hasPendingImageReferenceUploads = false,
onRememberImageModel,
optionLabelPrefix,
dimensionRatioAriaLabelPrefix,
@@ -192,7 +196,18 @@ export function ImageCanvasGenerationImageOptionsView({
};
const updateImageModel = (model: string) => {
onRememberImageModel(model);
if (hasPendingImageReferenceUploads) {
window.alert('参考图正在上传,请等待上传完成后再切换模型');
return;
}
const referenceLimit = resolveDialogExtraImageReferenceLimit(dialog, model);
const referenceCount = countDialogExtraImageReferences(dialog);
if (referenceCount > referenceLimit) {
window.alert(
`当前已有 ${referenceCount} 张参考图,${getEditorImageModelDisplayName(model)} 最多允许 ${referenceLimit} 张,请先删除多余参考图后再切换`,
);
return;
}
setGenerateDialog((currentDialog) => {
if (!currentDialog || currentDialog.mode !== dialog.mode) {
return currentDialog;
@@ -224,6 +239,7 @@ export function ImageCanvasGenerationImageOptionsView({
};
return resizeGenerationPlaceholderToImageSelection(nextDialog);
});
onRememberImageModel(model);
};
const togglePanel = (panel: Exclude<OpenPanel, null>) => {
@@ -408,7 +424,7 @@ export function ImageCanvasGenerationImageOptionsView({
size="xs"
shape="pill"
className={submitButtonClassName}
disabled={isGenerating}
disabled={isGenerating || hasPendingImageReferenceUploads}
aria-label={submitAriaLabel}
>
{isGenerating ? (
@@ -416,6 +416,79 @@ export const SEEDANCE_VIDEO_REFERENCE_LIMITS = {
audio: 3,
} as const;
export const QUICK_EDIT_REFERENCE_LIMIT = 8;
export const IMAGE_GENERATION_REFERENCE_LIMIT = 5;
export const ICON_EXTRA_REFERENCE_LIMIT = 8;
export const UI_EXTRA_REFERENCE_LIMIT = 5;
export function resolveImageProviderReferenceLimit(
model: string | null | undefined,
) {
return normalizeEditorImageModel(model) === IMAGE_MODEL_NANOBANANA2 ? 14 : 5;
}
export function resolveExtraImageReferenceLimit(
model: string | null | undefined,
productLimit: number,
primaryReferenceCount = 0,
) {
return Math.max(
0,
Math.min(
productLimit,
resolveImageProviderReferenceLimit(model) - primaryReferenceCount,
),
);
}
export function resolveDialogExtraImageReferenceLimit(
dialog: GenerateDialogState,
model: string | null | undefined = dialog.imageModel,
) {
if (dialog.mode === 'icon') {
return resolveExtraImageReferenceLimit(
model,
ICON_EXTRA_REFERENCE_LIMIT,
1,
);
}
if (dialog.mode === 'ui-design') {
return resolveExtraImageReferenceLimit(
model,
IMAGE_GENERATION_REFERENCE_LIMIT - 1,
1,
);
}
if (dialog.mode === 'quick-edit') {
return resolveExtraImageReferenceLimit(
model,
QUICK_EDIT_REFERENCE_LIMIT,
1,
);
}
if (dialog.mode === 'character') {
return resolveExtraImageReferenceLimit(
model,
IMAGE_GENERATION_REFERENCE_LIMIT - 1,
1,
);
}
return IMAGE_GENERATION_REFERENCE_LIMIT;
}
export function countDialogExtraImageReferences(dialog: GenerateDialogState) {
const references =
dialog.mode === 'character'
? dialog.characterReferences
: dialog.mode === 'publication'
? dialog.publicationReferences
: dialog.mode === 'video'
? []
: dialog.generationReferences;
return (references ?? []).filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
).length;
}
export const CHARACTER_ANIMATION_ACTION_PROMPTS = [
{ label: '待机', text: '待机动作,轻微呼吸起伏。' },
{ label: '行走', text: '循环行走动作,步伐稳定。' },
@@ -1004,6 +1077,20 @@ export function appendLimitedQuickEditReferences(
);
}
export function appendLimitedImageReferences(
references: CharacterReferenceImage[] | undefined,
nextReferences: CharacterReferenceImage[],
limit: number,
) {
const imageReferences = nextReferences.filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
);
return [...(references ?? []), ...imageReferences].slice(
0,
Math.max(0, limit),
);
}
export function createGenerationInputField(
title: string,
value: string | null | undefined,
@@ -1300,17 +1387,17 @@ export function isCanvasGenerationDialog(
): dialog is CanvasGenerationDialogState {
return Boolean(
dialog?.id &&
(dialog.mode === 'generate' ||
dialog.mode === 'spec' ||
dialog.mode === 'character' ||
dialog.mode === 'icon' ||
dialog.mode === 'publication' ||
dialog.mode === 'ui-design' ||
dialog.mode === 'quick-edit' ||
dialog.mode === 'character-animation' ||
dialog.mode === 'video' ||
dialog.mode === 'audio-sound-effect' ||
dialog.mode === 'audio-background-music'),
(dialog.mode === 'generate' ||
dialog.mode === 'spec' ||
dialog.mode === 'character' ||
dialog.mode === 'icon' ||
dialog.mode === 'publication' ||
dialog.mode === 'ui-design' ||
dialog.mode === 'quick-edit' ||
dialog.mode === 'character-animation' ||
dialog.mode === 'video' ||
dialog.mode === 'audio-sound-effect' ||
dialog.mode === 'audio-background-music'),
);
}
@@ -19,9 +19,7 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
calculateEditorIconSpritesheetPrice,
} from './ImageCanvasGenerationModel';
import { calculateEditorIconSpritesheetPrice } from './ImageCanvasGenerationModel';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -42,6 +40,7 @@ type ImageCanvasIconSpritesheetComposerViewProps = {
onRequestUpload: (target: UploadTarget) => void;
onUpdateIconDescriptionText: (value: string) => void;
onRememberImageModel: (model: string) => void;
hasPendingImageReferenceUploads?: boolean;
onSubmit: (dialog: GenerateDialogState) => void;
};
@@ -59,6 +58,7 @@ export function ImageCanvasIconSpritesheetComposerView({
onRequestUpload,
onUpdateIconDescriptionText,
onRememberImageModel,
hasPendingImageReferenceUploads = false,
onSubmit,
}: ImageCanvasIconSpritesheetComposerViewProps) {
const descriptionText =
@@ -236,6 +236,7 @@ export function ImageCanvasIconSpritesheetComposerView({
setGenerateDialog={setGenerateDialog}
includeDimensions
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
optionLabelPrefix="生成图片"
cost={calculateEditorIconSpritesheetPrice(
dialog.imageModel,
@@ -47,6 +47,7 @@ type ImageCanvasPublicationMaterialsDemoPanelViewProps = {
) => CSSProperties;
onRequestUpload: (target: UploadTarget) => void;
onRememberImageModel: (model: string) => void;
hasPendingImageReferenceUploads?: boolean;
onSubmit: (dialog: GenerateDialogState) => void;
};
@@ -116,6 +117,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
buildPortalMenuStyle,
onRequestUpload,
onRememberImageModel,
hasPendingImageReferenceUploads = false,
onSubmit,
}: ImageCanvasPublicationMaterialsDemoPanelViewProps) {
const references = dialog.publicationReferences ?? [];
@@ -302,6 +304,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
setGenerateDialog={setGenerateDialog}
includeDimensions={false}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
optionLabelPrefix="宣发素材"
cost={calculateEditorImageModelPrice(
@@ -13,7 +13,10 @@ import type {
QuickEditPanelState,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { QUICK_EDIT_REFERENCE_LIMIT } from './ImageCanvasGenerationModel';
import {
QUICK_EDIT_REFERENCE_LIMIT,
resolveExtraImageReferenceLimit,
} from './ImageCanvasGenerationModel';
export type ImageCanvasQuickEditPanelViewProps = {
panel: QuickEditPanelState;
@@ -31,6 +34,7 @@ export type ImageCanvasQuickEditPanelViewProps = {
) => CSSProperties;
onRequestUpload?: (target: UploadTarget) => void;
onRememberImageModel?: (model: string) => void;
hasPendingImageReferenceUploads?: boolean;
onSubmit: () => void;
};
@@ -100,13 +104,20 @@ export function ImageCanvasQuickEditPanelView({
buildPortalMenuStyle = () => ({}),
onRequestUpload,
onRememberImageModel = () => {},
hasPendingImageReferenceUploads = false,
onSubmit,
}: ImageCanvasQuickEditPanelViewProps) {
const isRedraw = panel.mode === 'redraw';
const isImageQuickEdit = !isRedraw && sourceLayer.mediaType !== 'video';
const referenceCount = panel.quickEditReferences?.length ?? 0;
const canAddReferences =
!isRedraw && referenceCount < QUICK_EDIT_REFERENCE_LIMIT;
!isRedraw &&
referenceCount <
resolveExtraImageReferenceLimit(
panel.model,
QUICK_EDIT_REFERENCE_LIMIT,
1,
);
const quickEditDialog = createQuickEditDialog(panel);
return (
@@ -141,6 +152,7 @@ export function ImageCanvasQuickEditPanelView({
}
onToggleReferenceMenu={() => setIsReferenceMenuOpen?.((open) => !open)}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmit}
dialogLabel={isRedraw ? '重绘图片' : '快速编辑图片'}
includeDimensions={isRedraw || isImageQuickEdit}
@@ -58,6 +58,7 @@ type ImageCanvasSpecGenerationPanelViewProps = {
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
onRequestUpload: (target: UploadTarget) => void;
onRememberImageModel?: (model: string) => void;
hasPendingImageReferenceUploads?: boolean;
onSubmit: (dialog: GenerateDialogState) => void;
};
@@ -79,6 +80,7 @@ export function ImageCanvasSpecGenerationPanelView({
onUpdateSpecFormValue,
onRequestUpload,
onRememberImageModel = () => {},
hasPendingImageReferenceUploads = false,
onSubmit,
}: ImageCanvasSpecGenerationPanelViewProps) {
const isUiDesignDialog = dialog.mode === 'ui-design';
@@ -428,6 +430,7 @@ export function ImageCanvasSpecGenerationPanelView({
setGenerateDialog={setGenerateDialog ?? (() => undefined)}
includeDimensions
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
cost={calculateEditorUiDesignPrice(
IMAGE_MODEL_GPT_IMAGE_2,
@@ -473,7 +476,7 @@ export function ImageCanvasSpecGenerationPanelView({
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit"
disabled={isGenerating}
disabled={isGenerating || hasPendingImageReferenceUploads}
aria-label="提交生成规范"
>
{isGenerating ? (
@@ -76,6 +76,7 @@ export type ImageCanvasStageViewProps = {
uiAssetExtractionSourceLayer: CanvasLayer | null;
quickEditSelectionState: UiAssetExtractionState | null;
quickEditSelectionSourceLayer: CanvasLayer | null;
hasPendingImageReferenceUploads?: boolean;
generationComposerStyle: CSSProperties | null;
selectedToolbarStyle: CSSProperties | null;
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
@@ -236,6 +237,7 @@ export function ImageCanvasStageView({
uiAssetExtractionSourceLayer,
quickEditSelectionState,
quickEditSelectionSourceLayer,
hasPendingImageReferenceUploads = false,
generationComposerStyle,
selectedToolbarStyle,
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
@@ -445,6 +447,7 @@ export function ImageCanvasStageView({
onRequestUpload={onRequestUiAssetExtractionReferenceUpload}
onRemoveReference={onRemoveUiAssetExtractionReference}
onSubmit={onSubmitUiAssetExtraction}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
/>
<ImageCanvasUiAssetExtractionOverlayView
@@ -27,6 +27,7 @@ export type UiAssetExtractionMark =
| UiAssetExtractionBrushMark;
export type UiAssetExtractionState = {
referenceUploadContextId?: string;
sourceLayerId: string;
tool: UiAssetExtractionTool | null;
model: string;
@@ -236,6 +236,79 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
expect(onModelChange).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2);
});
it('disables extraction submission while reference uploads are pending', () => {
render(
<ImageCanvasUiAssetExtractionOverlayView
sourceLayer={createLayer({ src: 'data:image/png;base64,ui' })}
viewport={{ x: 10, y: 20, scale: 1 }}
state={createState({
marks: [
{
id: 'mark-1',
tool: 'rect',
x: 10,
y: 20,
width: 80,
height: 60,
},
],
})}
onToolChange={vi.fn()}
onPointerStart={vi.fn()}
onPointerMove={vi.fn()}
onPointerEnd={vi.fn()}
onSubmit={vi.fn()}
hasPendingImageReferenceUploads
/>,
);
expect(
(screen.getByRole('button', { name: '提取' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it('keeps the current UI extraction model and references when the target model is too small', () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
const onModelChange = vi.fn();
render(
<ImageCanvasUiAssetExtractionOverlayView
sourceLayer={createLayer()}
viewport={{ x: 10, y: 20, scale: 1 }}
state={createState({
references: Array.from({ length: 5 }, (_, index) => ({
id: `reference-${index + 1}`,
label: `参考图${index + 1}`,
src: `/reference-${index + 1}.png`,
})),
})}
onToolChange={vi.fn()}
onPointerStart={vi.fn()}
onPointerMove={vi.fn()}
onPointerEnd={vi.fn()}
onModelChange={onModelChange}
onSubmit={vi.fn()}
/>,
);
fireEvent.click(
screen.getByRole('button', { name: '提取素材模型 nanobanana2' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '提取素材模型选项' })).getByRole(
'button',
{ name: 'gpt-image-2' },
),
);
expect(alertMock).toHaveBeenCalledWith(
'当前已有 5 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换',
);
expect(onModelChange).not.toHaveBeenCalled();
expect(screen.getByRole('menu', { name: '提取素材模型选项' })).toBeTruthy();
});
it('crops mark previews from the same source coordinates as the red selection', () => {
render(
<ImageCanvasUiAssetExtractionOverlayView
@@ -22,6 +22,8 @@ import {
calculateEditorIconSpritesheetPrice,
EDITOR_IMAGE_MODEL_OPTIONS,
getEditorImageModelDisplayName,
resolveExtraImageReferenceLimit,
UI_EXTRA_REFERENCE_LIMIT,
} from './ImageCanvasGenerationModel';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import type {
@@ -48,6 +50,7 @@ type ImageCanvasUiAssetExtractionOverlayViewProps = {
onRequestUpload?: () => void;
onRemoveReference?: (referenceId: string) => void;
onSubmit?: () => void;
hasPendingImageReferenceUploads?: boolean;
};
const TOOL_OPTIONS: Array<{
@@ -275,6 +278,7 @@ export function ImageCanvasUiAssetExtractionOverlayView({
onRequestUpload,
onRemoveReference,
onSubmit,
hasPendingImageReferenceUploads = false,
}: ImageCanvasUiAssetExtractionOverlayViewProps) {
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
if (!sourceLayer || !state) {
@@ -282,7 +286,10 @@ export function ImageCanvasUiAssetExtractionOverlayView({
}
const isQuickEdit = variant === 'quick-edit';
const marks = [...state.marks, ...(state.draftMark ? [state.draftMark] : [])];
const canSubmit = state.marks.length > 0 && state.status !== 'extracting';
const canSubmit =
state.marks.length > 0 &&
state.status !== 'extracting' &&
!hasPendingImageReferenceUploads;
const extractionPlan = resolveUiAssetExtractionGenerationPlan(
state.marks.length,
);
@@ -487,6 +494,18 @@ export function ImageCanvasUiAssetExtractionOverlayView({
className="image-canvas-editor__option-popover-choice image-canvas-editor__option-popover-choice--model"
aria-pressed={selected}
onClick={() => {
const referenceLimit =
resolveExtraImageReferenceLimit(
option.value,
UI_EXTRA_REFERENCE_LIMIT,
1,
);
if (state.references.length > referenceLimit) {
window.alert(
`当前已有 ${state.references.length} 张参考图,${option.label} 最多允许 ${referenceLimit} 张,请先删除多余参考图后再切换`,
);
return;
}
onModelChange(option.value);
setIsModelMenuOpen(false);
}}
@@ -10,7 +10,12 @@ import type {
GenerateDialogState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
import { appendLimitedQuickEditReferences } from './ImageCanvasGenerationModel';
import {
appendLimitedImageReferences,
QUICK_EDIT_REFERENCE_LIMIT,
resolveDialogExtraImageReferenceLimit,
resolveExtraImageReferenceLimit,
} from './ImageCanvasGenerationModel';
type CanvasSize = { width: number; height: number };
type CanvasPoint = { x: number; y: number };
@@ -173,6 +178,7 @@ export function applyGenerationReferenceUpload({
? {
...setFailedGenerationIdle(dialog),
characterSpecReference: firstReference,
characterReferences: dialog.characterReferences,
}
: dialog;
}
@@ -181,6 +187,11 @@ export function applyGenerationReferenceUpload({
? {
...setFailedGenerationIdle(dialog),
iconSpecReference: firstReference,
generationReferences: appendLimitedImageReferences(
[],
dialog.generationReferences ?? [],
resolveDialogExtraImageReferenceLimit(dialog),
),
}
: dialog;
}
@@ -189,6 +200,7 @@ export function applyGenerationReferenceUpload({
? {
...setFailedGenerationIdle(dialog),
uiDesignSpecReference: firstReference,
generationReferences: dialog.generationReferences,
}
: dialog;
}
@@ -196,10 +208,11 @@ export function applyGenerationReferenceUpload({
return dialog?.mode === 'publication'
? {
...setFailedGenerationIdle(dialog),
publicationReferences: [
...(dialog.publicationReferences ?? []),
...references,
],
publicationReferences: appendLimitedImageReferences(
dialog.publicationReferences,
references,
resolveDialogExtraImageReferenceLimit(dialog),
),
composerOpen: true,
}
: dialog;
@@ -212,10 +225,18 @@ export function applyGenerationReferenceUpload({
dialog?.mode === 'ui-design'
? {
...setFailedGenerationIdle(dialog),
generationReferences: [
...(dialog.generationReferences ?? []),
...references,
],
generationReferences:
dialog.mode === 'video'
? appendLimitedVideoReferences(
dialog.generationReferences ?? [],
references,
'image',
)
: appendLimitedImageReferences(
dialog.generationReferences,
references,
resolveDialogExtraImageReferenceLimit(dialog),
),
}
: dialog;
}
@@ -245,10 +266,11 @@ export function applyGenerationReferenceUpload({
return dialog?.mode === 'character'
? {
...setFailedGenerationIdle(dialog),
characterReferences: [
...(dialog.characterReferences ?? []),
...references,
],
characterReferences: appendLimitedImageReferences(
dialog.characterReferences,
references,
resolveDialogExtraImageReferenceLimit(dialog),
),
}
: dialog;
}
@@ -267,9 +289,14 @@ export function applyQuickEditReferenceUpload({
...panel,
status: panel.status === 'failed' ? 'idle' : panel.status,
errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
quickEditReferences: appendLimitedQuickEditReferences(
quickEditReferences: appendLimitedImageReferences(
panel.quickEditReferences,
references,
resolveExtraImageReferenceLimit(
panel.model,
QUICK_EDIT_REFERENCE_LIMIT,
1,
),
),
};
}
@@ -72,6 +72,78 @@ function durablePerfectPixelDialog(
}
describe('useCanvasGenerationDialogs', () => {
it('keeps the active dialog context stable while reference uploads are pending', () => {
let locked = false;
const onContextMutationRejected = vi.fn();
const { result } = renderHook(() =>
useCanvasGenerationDialogs({
isContextMutationLocked: () => locked,
onContextMutationRejected,
}),
);
act(() => {
result.current.openCanvasGenerationDialog(
createDialog('generate', 'first'),
);
});
const activeId = result.current.activeCanvasGenerationDialog?.id;
expect(activeId).toBeTruthy();
locked = true;
act(() => {
result.current.openCanvasGenerationDialog(
createDialog('character', 'second'),
);
result.current.setGenerateDialog(null);
});
expect(result.current.activeCanvasGenerationDialog).toEqual(
expect.objectContaining({ id: activeId, prompt: 'first' }),
);
expect(result.current.inactiveGenerateDialogs).toEqual([]);
expect(onContextMutationRejected).toHaveBeenCalledTimes(2);
act(() => {
result.current.setGenerateDialog((currentDialog) =>
currentDialog ? { ...currentDialog, prompt: 'updated' } : currentDialog,
);
});
expect(result.current.activeCanvasGenerationDialog?.prompt).toBe('updated');
});
it('rejects switching between same-mode non-canvas dialogs while uploads are pending', () => {
let locked = false;
const onContextMutationRejected = vi.fn();
const { result } = renderHook(() =>
useCanvasGenerationDialogs({
isContextMutationLocked: () => locked,
onContextMutationRejected,
}),
);
act(() => {
result.current.setGenerateDialog({
mode: 'edit',
sourceLayerId: 'layer-a',
prompt: '',
status: 'idle',
});
});
locked = true;
act(() => {
result.current.setGenerateDialog({
mode: 'edit',
sourceLayerId: 'layer-b',
prompt: '',
status: 'idle',
});
});
expect(result.current.generateDialog?.sourceLayerId).toBe('layer-a');
expect(onContextMutationRejected).toHaveBeenCalledTimes(1);
});
it('archives, activates, updates, and removes canvas generation dialogs', () => {
const onActivate = vi.fn();
const { result } = renderHook(() =>
@@ -75,10 +75,23 @@ function withGenerationTimestamps<T extends GenerateDialogState | null>(
};
}
function generationDialogContextId(dialog: GenerateDialogState | null) {
if (!dialog) {
return null;
}
return isCanvasGenerationDialog(dialog)
? dialog.id
: `${dialog.mode}:${dialog.sourceLayerId ?? 'draft'}`;
}
export function useCanvasGenerationDialogs({
onActivate,
isContextMutationLocked,
onContextMutationRejected,
}: {
onActivate?: () => void;
isContextMutationLocked?: () => boolean;
onContextMutationRejected?: () => void;
} = {}) {
const generationDialogCounterRef = useRef(0);
const generateDialogRef = useRef<GenerateDialogState | null>(null);
@@ -96,21 +109,37 @@ export function useCanvasGenerationDialogs({
? generateDialog
: null;
const rejectContextMutation = useCallback(() => {
if (!isContextMutationLocked?.()) {
return false;
}
onContextMutationRejected?.();
return true;
}, [isContextMutationLocked, onContextMutationRejected]);
const setGenerateDialog = useCallback<
Dispatch<SetStateAction<GenerateDialogState | null>>
>((nextDialogOrUpdater) => {
const currentDialog = generateDialogRef.current;
const nextDialog =
typeof nextDialogOrUpdater === 'function'
? nextDialogOrUpdater(currentDialog)
: nextDialogOrUpdater;
const nextDialogWithTimestamps = withGenerationTimestamps(
nextDialog,
currentDialog,
);
generateDialogRef.current = nextDialogWithTimestamps;
setGenerateDialogState(nextDialogWithTimestamps);
}, []);
>(
(nextDialogOrUpdater) => {
const currentDialog = generateDialogRef.current;
const nextDialog =
typeof nextDialogOrUpdater === 'function'
? nextDialogOrUpdater(currentDialog)
: nextDialogOrUpdater;
const nextDialogWithTimestamps = withGenerationTimestamps(
nextDialog,
currentDialog,
);
const currentContextId = generationDialogContextId(currentDialog);
const nextContextId = generationDialogContextId(nextDialogWithTimestamps);
if (currentContextId !== nextContextId && rejectContextMutation()) {
return;
}
generateDialogRef.current = nextDialogWithTimestamps;
setGenerateDialogState(nextDialogWithTimestamps);
},
[rejectContextMutation],
);
const canvasGenerationDialogs = useMemo(
() =>
activeCanvasGenerationDialog
@@ -137,6 +166,9 @@ export function useCanvasGenerationDialogs({
if (!isCanvasGenerationDialog(currentDialog)) {
return;
}
if (rejectContextMutation()) {
return;
}
const nextInactiveDialogs = inactiveGenerateDialogsRef.current.some(
(dialog) => dialog.id === currentDialog.id,
)
@@ -150,11 +182,14 @@ export function useCanvasGenerationDialogs({
];
inactiveGenerateDialogsRef.current = nextInactiveDialogs;
setInactiveGenerateDialogs(nextInactiveDialogs);
}, []);
}, [rejectContextMutation]);
const openCanvasGenerationDialog = useCallback(
(dialog: CanvasGenerationDialogDraft) => {
const currentDialog = generateDialogRef.current;
if (rejectContextMutation()) {
return isCanvasGenerationDialog(currentDialog) ? currentDialog.id : '';
}
if (isCanvasGenerationDialog(currentDialog)) {
inactiveGenerateDialogsRef.current =
inactiveGenerateDialogsRef.current.some(
@@ -193,12 +228,21 @@ export function useCanvasGenerationDialogs({
archiveActiveCanvasGenerationDialog,
createGenerationDialogId,
getCanvasGenerationDialogsSnapshot,
rejectContextMutation,
],
);
const updateCanvasGenerationDialogById = useCallback(
(dialogId: string, updater: CanvasGenerationDialogUpdater) => {
const currentDialogRef = generateDialogRef.current;
if (
isCanvasGenerationDialog(currentDialogRef) &&
currentDialogRef.id === dialogId &&
updater(currentDialogRef) === null &&
rejectContextMutation()
) {
return;
}
if (
isCanvasGenerationDialog(currentDialogRef) &&
currentDialogRef.id === dialogId
@@ -235,7 +279,7 @@ export function useCanvasGenerationDialogs({
}),
);
},
[],
[rejectContextMutation],
);
// 中文注释:低层删除不再对未收口的完美像素 operation 抗命。删除占位不撤销任何在途请求
@@ -273,6 +317,13 @@ export function useCanvasGenerationDialogs({
const activateCanvasGenerationDialog = useCallback(
(targetDialog: CanvasGenerationDialogState) => {
const currentDialog = generateDialogRef.current;
if (
(!isCanvasGenerationDialog(currentDialog) ||
currentDialog.id !== targetDialog.id) &&
rejectContextMutation()
) {
return;
}
const nextInactiveDialogs = inactiveGenerateDialogsRef.current.filter(
(dialog) => dialog.id !== targetDialog.id,
);
@@ -295,7 +346,7 @@ export function useCanvasGenerationDialogs({
setGenerateDialogState(nextActiveDialog);
onActivate?.();
},
[onActivate],
[onActivate, rejectContextMutation],
);
const restoreCanvasGenerationDialogs = useCallback(
@@ -341,6 +392,13 @@ export function useCanvasGenerationDialogs({
dialog.sourceLayerId !== targetLayerId &&
dialog.generatedLayerId !== targetLayerId;
const currentDialog = generateDialogRef.current;
if (
isCanvasGenerationDialog(currentDialog) &&
!keepDialog(currentDialog) &&
rejectContextMutation()
) {
return;
}
const nextActiveDialog =
isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog)
? null
@@ -352,7 +410,7 @@ export function useCanvasGenerationDialogs({
setGenerateDialogState(nextActiveDialog);
setInactiveGenerateDialogs(nextInactiveDialogs);
},
[],
[rejectContextMutation],
);
const getGeneratingDialogPlaceholder = useCallback(
@@ -76,7 +76,10 @@ function AssetCanvasBridgeHarness({
selectSingleLayer = vi.fn(),
}: {
asset?: EditorAsset;
resolveCanvasPoint?: (clientX: number, clientY: number) => {
resolveCanvasPoint?: (
clientX: number,
clientY: number,
) => {
x: number;
y: number;
} | null;
@@ -97,7 +100,9 @@ function AssetCanvasBridgeHarness({
const suppressAssetClickRef = useRef(false);
const layerCounterRef = useRef(0);
const [activeUploadFolderId, setActiveUploadFolderId] = useState('project');
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>('hovered');
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(
'hovered',
);
const [assetPointerDrag, setAssetPointerDrag] =
useState<AssetPointerDragState | null>(assetPointerDragRef.current);
const [uploadDropTarget, setUploadDropTarget] = useState<
@@ -132,10 +137,7 @@ function AssetCanvasBridgeHarness({
return (
<div>
<button
type="button"
onClick={() => bridge.addAssetLayer(asset)}
>
<button type="button" onClick={() => bridge.addAssetLayer(asset)}>
添加到画布
</button>
<span data-testid="folder">{activeUploadFolderId}</span>
@@ -158,17 +160,21 @@ function AssetCleanupHarness({
}),
],
onDeleteLayerSideEffects = vi.fn(),
canDeleteLayers,
discardHistoryEntriesContainingLayer = vi.fn(),
}: {
deletedAssets?: EditorAsset[];
initialLayers?: CanvasLayer[];
onDeleteLayerSideEffects?: (layerId: string) => void;
canDeleteLayers?: (targetLayerIds: string[]) => boolean;
discardHistoryEntriesContainingLayer?: (
matchesLayer: (layer: CanvasLayer) => boolean,
) => void;
}) {
const [layers, setLayers] = useState(initialLayers);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>('linked');
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(
'linked',
);
const [selectedLayerIds, setSelectedLayerIds] = useState(['linked', 'kept']);
const cleanup = useImageCanvasAssetLayerCleanup({
layers,
@@ -176,6 +182,7 @@ function AssetCleanupHarness({
setSelectedLayerId,
setSelectedLayerIds,
onDeleteLayerSideEffects,
canDeleteLayers,
discardHistoryEntriesContainingLayer,
});
@@ -184,7 +191,9 @@ function AssetCleanupHarness({
<button type="button" onClick={() => cleanup(deletedAssets)}>
清理素材
</button>
<span data-testid="layers">{layers.map((layer) => layer.id).join(',')}</span>
<span data-testid="layers">
{layers.map((layer) => layer.id).join(',')}
</span>
<span data-testid="selected">{selectedLayerId ?? '-'}</span>
<span data-testid="selected-many">{selectedLayerIds.join(',')}</span>
</div>
@@ -292,6 +301,32 @@ describe('useImageCanvasAssetCanvasBridge', () => {
).toBe(false);
});
it('keeps linked layers and history when asset cleanup is blocked', () => {
const onDeleteLayerSideEffects = vi.fn();
const discardHistoryEntriesContainingLayer = vi.fn();
const canDeleteLayers = vi.fn().mockReturnValue(false);
render(
<AssetCleanupHarness
deletedAssets={[defaultAsset]}
onDeleteLayerSideEffects={onDeleteLayerSideEffects}
canDeleteLayers={canDeleteLayers}
discardHistoryEntriesContainingLayer={
discardHistoryEntriesContainingLayer
}
/>,
);
act(() => {
screen.getByRole('button', { name: '清理素材' }).click();
});
expect(canDeleteLayers).toHaveBeenCalledWith(['linked']);
expect(screen.getByTestId('layers').textContent).toBe('linked,kept');
expect(screen.getByTestId('selected').textContent).toBe('linked');
expect(onDeleteLayerSideEffects).not.toHaveBeenCalled();
expect(discardHistoryEntriesContainingLayer).not.toHaveBeenCalled();
});
it('invalidates matching history even when no linked layer is currently mounted', () => {
const discardHistoryEntriesContainingLayer = vi.fn();
render(
@@ -36,6 +36,7 @@ type UseImageCanvasAssetLayerCleanupOptions = {
setSelectedLayerId: Dispatch<SetStateAction<string | null>>;
setSelectedLayerIds: Dispatch<SetStateAction<string[]>>;
onDeleteLayerSideEffects?: (layerId: string) => void;
canDeleteLayers?: (targetLayerIds: string[]) => boolean;
discardHistoryEntriesContainingLayer?: (
matchesLayer: (layer: CanvasLayer) => boolean,
) => void;
@@ -73,21 +74,28 @@ export function useImageCanvasAssetLayerCleanup({
setSelectedLayerId,
setSelectedLayerIds,
onDeleteLayerSideEffects,
canDeleteLayers,
discardHistoryEntriesContainingLayer,
}: UseImageCanvasAssetLayerCleanupOptions) {
return useCallback(
(deletedAssets: EditorAsset[]) => {
if (!deletedAssets.length) {
return;
return true;
}
discardHistoryEntriesContainingLayer?.((layer) =>
deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)),
);
const deletedLayerIds = layers
.filter((layer) =>
deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)),
)
.map((layer) => layer.id);
if (
deletedLayerIds.length > 0 &&
canDeleteLayers?.(deletedLayerIds) === false
) {
return false;
}
discardHistoryEntriesContainingLayer?.((layer) =>
deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)),
);
setLayers((currentLayers) =>
currentLayers.filter(
(layer) =>
@@ -111,16 +119,18 @@ export function useImageCanvasAssetLayerCleanup({
}
const currentLayer = layers.find((layer) => layer.id === currentId);
return currentLayer &&
deletedAssets.some((asset) => isLayerLinkedToAsset(currentLayer, asset))
deletedAssets.some((asset) =>
isLayerLinkedToAsset(currentLayer, asset),
)
? null
: currentId;
});
deletedLayerIds.forEach((layerId) =>
onDeleteLayerSideEffects?.(layerId),
);
deletedLayerIds.forEach((layerId) => onDeleteLayerSideEffects?.(layerId));
return true;
},
[
discardHistoryEntriesContainingLayer,
canDeleteLayers,
layers,
onDeleteLayerSideEffects,
setLayers,
@@ -36,7 +36,9 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
};
});
function createUploadedAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
function createUploadedAsset(
overrides: Partial<EditorAsset> = {},
): EditorAsset {
return {
id: 'asset-a',
label: '素材A',
@@ -73,7 +75,7 @@ function AssetLibraryHarness({
}: {
canAccessProtectedData?: boolean;
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
onDeleteAssets?: (assets: EditorAsset[]) => void;
onDeleteAssets?: (assets: EditorAsset[]) => boolean | void;
}) {
const assetListRef = useRef<HTMLDivElement | null>(null);
const assetLibrary = useImageCanvasAssetLibrary({
@@ -292,9 +294,7 @@ describe('useImageCanvasAssetLibrary', () => {
}),
);
render(
<AssetLibraryHarness openEditorLoginModal={openEditorLoginModal} />,
);
render(<AssetLibraryHarness openEditorLoginModal={openEditorLoginModal} />);
await waitFor(() => {
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
@@ -330,9 +330,7 @@ describe('useImageCanvasAssetLibrary', () => {
});
act(() => screen.getByRole('button', { name: 'commit folder' }).click());
await waitFor(() => {
expect(screen.getByTestId('folders').textContent).toContain(
'folder-',
);
expect(screen.getByTestId('folders').textContent).toContain('folder-');
});
act(() => {
resolveCreateFolder({
@@ -402,6 +400,20 @@ describe('useImageCanvasAssetLibrary', () => {
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
});
it('keeps uploaded assets when canvas cleanup vetoes deletion', async () => {
const onDeleteAssets = vi.fn().mockReturnValue(false);
render(<AssetLibraryHarness onDeleteAssets={onDeleteAssets} />);
await screen.findByText('素材A');
act(() => screen.getByRole('button', { name: 'delete asset' }).click());
expect(screen.getByTestId('assets').textContent).toContain('asset-a:素材A');
expect(onDeleteAssets).toHaveBeenCalledWith([
expect.objectContaining({ id: 'asset-a' }),
]);
expect(deleteEditorAssetMock).not.toHaveBeenCalled();
});
it('selects and deletes selected uploaded assets', async () => {
const onDeleteAssets = vi.fn();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
@@ -120,7 +120,7 @@ export function useImageCanvasAssetLibrary({
assetListRef: RefObject<HTMLDivElement | null>;
canAccessProtectedData: boolean;
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
onDeleteAssets?: (assets: EditorAsset[]) => void;
onDeleteAssets?: (assets: EditorAsset[]) => boolean | void;
}) {
const [assetFolders, setAssetFolders] =
useState<EditorAssetFolder[]>(EDITOR_ASSET_FOLDERS);
@@ -353,8 +353,10 @@ export function useImageCanvasAssetLibrary({
if (asset.sourceKind !== 'uploaded') {
return;
}
if (onDeleteAssets?.([asset]) === false) {
return;
}
setAssets((currentAssets) => removeAssetById(currentAssets, asset.id));
onDeleteAssets?.([asset]);
setRenamingAsset((currentRename) =>
currentRename?.assetId === asset.id ? null : currentRename,
);
@@ -506,10 +508,12 @@ export function useImageCanvasAssetLibrary({
const ids = [...selectedAssetIds];
const deletedAssets = removeSelectedAssets(assets, selectedAssetIds)
.deletedAssets;
if (onDeleteAssets?.(deletedAssets) === false) {
return;
}
setAssets(
(currentAssets) => removeSelectedAssets(currentAssets, selectedAssetIds).assets,
);
onDeleteAssets?.(deletedAssets);
setSelectedAssetIds(new Set());
ids.forEach((assetId) => {
void deleteEditorAsset(assetId);

Some files were not shown because too many files have changed in this diff Show More