Files
Genarrative/src/components/image-editor/useImageCanvasGenerationWorkflow.ts
T
JenkenB 7b277480f0 Save generator snapshots in canvas layout
Persist generation-dialog snapshots into the canvas layout and restore them on load. Added serialization/hydration helpers and layout splitting in ImageCanvasEditorModel to store/load generation dialogs alongside layers; updated view and hooks to restore dialogs and keep composer state after refresh. Hide generation placeholders when a generated result layer exists, and persist generated images into the account-level asset library (createEditorAsset) for non-video results. Updated workflows and surfaces to invoke the new persist flow, added tests covering restore, placeholder hiding and asset creation, and updated docs/decision log to reflect the new behavior.
2026-06-18 11:57:28 +08:00

633 lines
19 KiB
TypeScript

import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useCallback,
useMemo,
useState,
} from 'react';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasTool,
CanvasViewport,
CharacterAnimationPanelState,
GenerateDialogState,
ImageContextMenuState,
QuickEditPanelState,
SidebarPanel,
SpecFormValues,
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import {
appendCharacterReference,
appendGenerationReference,
appendIconDescriptionToDialog,
assignUiDesignSpecReference,
assignCharacterSpecReference,
assignIconSpecReference,
closeGenerateComposerDialog,
createCharacterAnimationPanelDraft,
createCharacterGenerationDialogDraft,
createEditDialogDraft,
createGenerateDialogDraft,
createIconGenerationDialogDraft,
createQuickEditPanelDraft,
createSpecDialogDraft,
createUiDesignGenerationDialogDraft,
createVideoGenerationDialogDraft,
hideGeneratedLayerComposerAfterBlur,
updateCharacterAnimationDurationPanel,
updateIconDescriptionInDialog,
updateSpecFormDialogValue,
} from './ImageCanvasGenerationDialogModel';
import {
centerViewportOnPlacement,
chooseGenerationPlacement,
} from './ImageCanvasGenerationPlacementModel';
import {
buildQuickEditModelOptions,
buildQuickEditSizeOptions,
calculateCharacterAnimationPrice,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
} from './ImageCanvasGenerationModel';
import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow';
type CanvasSize = { width: number; height: number };
type CanvasGenerationDialogUpdater = (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null;
type GenerationWorkflowOptions = {
layers: CanvasLayer[];
canvasSize: CanvasSize;
viewport: CanvasViewport;
setViewport: Dispatch<SetStateAction<CanvasViewport>>;
canvasGenerationDialogs: CanvasGenerationDialogState[];
layerCounterRef: MutableRefObject<number>;
generateDialog: GenerateDialogState | null;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
openCanvasGenerationDialog: (
dialog: Omit<CanvasGenerationDialogState, 'id'>,
) => void;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
) => GenerateDialogState['placeholder'];
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
selectSingleLayer: (layerId: string | null) => void;
fitLayers: (targetLayers?: CanvasLayer[]) => void;
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
persistGeneratedAsset?: (layer: CanvasLayer) => void;
};
export function useImageCanvasGenerationWorkflow({
layers,
canvasSize,
viewport,
setViewport,
canvasGenerationDialogs,
layerCounterRef,
generateDialog,
setGenerateDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
fitLayers,
setActiveTool,
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
persistGeneratedAsset,
}: GenerationWorkflowOptions) {
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
useState(false);
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false);
const [isCharacterReferenceMenuOpen, setIsCharacterReferenceMenuOpen] =
useState(false);
const [
isPickingGenerationReferenceFromCanvas,
setIsPickingGenerationReferenceFromCanvas,
] = useState(false);
const [
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
] = useState(false);
const [
isPickingCharacterReferenceFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
] = useState(false);
const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(false);
const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] =
useState(false);
const [isUiDesignSpecMenuOpen, setIsUiDesignSpecMenuOpen] = useState(false);
const [isPickingUiDesignSpecFromCanvas, setIsPickingUiDesignSpecFromCanvas] =
useState(false);
const [quickEditPanel, setQuickEditPanel] =
useState<QuickEditPanelState | null>(null);
const [characterAnimationPanel, setCharacterAnimationPanel] =
useState<CharacterAnimationPanelState | null>(null);
const [lastImageModel, setLastImageModel] = useState(DEFAULT_IMAGE_MODEL);
const quickEditSourceLayer = quickEditPanel
? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ??
null)
: null;
const characterAnimationSourceLayer = characterAnimationPanel
? (layers.find(
(layer) => layer.id === characterAnimationPanel.sourceLayerId,
) ?? null)
: null;
const quickEditSizeOptions = useMemo(
() =>
quickEditPanel ? buildQuickEditSizeOptions(quickEditPanel.size) : [],
[quickEditPanel],
);
const quickEditModelOptions = useMemo(
() =>
quickEditPanel ? buildQuickEditModelOptions(quickEditPanel.model) : [],
[quickEditPanel],
);
const characterAnimationPrice = characterAnimationPanel
? calculateCharacterAnimationPrice(
characterAnimationPanel.resolution,
characterAnimationPanel.durationSeconds,
)
: 0;
const iconDescriptionValues =
generateDialog?.mode === 'icon'
? (generateDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS)
: DEFAULT_ICON_DESCRIPTIONS;
const closeGenerationTransientState = useCallback(() => {
setIsSpecMenuOpen(false);
setIsGenerationReferenceMenuOpen(false);
setIsCharacterSpecMenuOpen(false);
setIsCharacterReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(false);
setIsPickingCharacterSpecFromCanvas(false);
setIsPickingCharacterReferenceFromCanvas(false);
setIsIconSpecMenuOpen(false);
setIsPickingIconSpecFromCanvas(false);
setIsUiDesignSpecMenuOpen(false);
setIsPickingUiDesignSpecFromCanvas(false);
setImageContextMenu(null);
}, [setImageContextMenu]);
const openPlacedCanvasGenerationDialog = useCallback(
(draft: Omit<CanvasGenerationDialogState, 'id'>) => {
const draftPlaceholder = draft.placeholder;
if (!draftPlaceholder) {
openCanvasGenerationDialog(draft);
return;
}
// 中文注释:所有画布生成入口统一先走 placement 模型,避免新占位压住已有图层或生成占位。
const placement = chooseGenerationPlacement({
canvasSize,
viewport,
frame: draftPlaceholder,
layers,
generationDialogs: canvasGenerationDialogs,
});
openCanvasGenerationDialog({
...draft,
placeholder: placement,
});
setViewport(
centerViewportOnPlacement({
canvasSize,
viewport,
placement,
}),
);
},
[
canvasGenerationDialogs,
canvasSize,
layers,
openCanvasGenerationDialog,
setViewport,
viewport,
],
);
const activateCanvasGenerationEntry = useCallback(
(activeTool: CanvasTool) => {
closeGenerationTransientState();
setActiveTool(activeTool);
selectSingleLayer(null);
setQuickEditPanel(null);
setCharacterAnimationPanel(null);
},
[closeGenerationTransientState, selectSingleLayer, setActiveTool],
);
const openGenerateDialog = useCallback(() => {
openPlacedCanvasGenerationDialog(
createGenerateDialogDraft({ canvasSize, viewport }),
);
activateCanvasGenerationEntry('generate');
}, [
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
viewport,
]);
const openSpecDialog = useCallback(
(specType: SpecGenerationType) => {
openPlacedCanvasGenerationDialog(
createSpecDialogDraft({ canvasSize, viewport, specType }),
);
activateCanvasGenerationEntry('generate');
},
[
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
viewport,
],
);
const openCharacterAnimationPanel = useCallback(
(layer: CanvasLayer) => {
const nextPanel = createCharacterAnimationPanelDraft(layer);
if (!nextPanel) {
return;
}
setImageContextMenu(null);
setQuickEditPanel(null);
setCharacterAnimationPanel(nextPanel);
selectSingleLayer(layer.id);
},
[selectSingleLayer, setImageContextMenu],
);
const openCharacterGenerationDialog = useCallback(() => {
openPlacedCanvasGenerationDialog(
createCharacterGenerationDialogDraft({
canvasSize,
viewport,
imageModel: lastImageModel,
}),
);
activateCanvasGenerationEntry('character');
}, [
activateCanvasGenerationEntry,
canvasSize,
lastImageModel,
openPlacedCanvasGenerationDialog,
viewport,
]);
const openIconGenerationDialog = useCallback(() => {
openPlacedCanvasGenerationDialog(
createIconGenerationDialogDraft({
canvasSize,
viewport,
imageModel: lastImageModel,
}),
);
activateCanvasGenerationEntry('icon');
}, [
activateCanvasGenerationEntry,
canvasSize,
lastImageModel,
openPlacedCanvasGenerationDialog,
viewport,
]);
const openVideoGenerationDialog = useCallback(() => {
openPlacedCanvasGenerationDialog(
createVideoGenerationDialogDraft({ canvasSize, viewport }),
);
activateCanvasGenerationEntry('video');
}, [
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
viewport,
]);
const openUiDesignGenerationDialog = useCallback(() => {
openPlacedCanvasGenerationDialog(
createUiDesignGenerationDialogDraft({
canvasSize,
viewport,
imageModel: 'gpt-image-2',
}),
);
activateCanvasGenerationEntry('ui-design');
}, [
activateCanvasGenerationEntry,
canvasSize,
openPlacedCanvasGenerationDialog,
viewport,
]);
const openEditDialog = useCallback(
(sourceLayer: CanvasLayer) => {
setMetadataLayer(null);
setImageContextMenu(null);
setQuickEditPanel(null);
setGenerateDialog(createEditDialogDraft(sourceLayer));
setActiveTool('generate');
},
[setActiveTool, setGenerateDialog, setImageContextMenu, setMetadataLayer],
);
const openQuickEditPanel = useCallback(
(sourceLayer: CanvasLayer) => {
setImageContextMenu(null);
setMetadataLayer(null);
setGenerateDialog(null);
setCharacterAnimationPanel(null);
setQuickEditPanel(createQuickEditPanelDraft(sourceLayer));
selectSingleLayer(sourceLayer.id);
setActiveTool('generate');
},
[
selectSingleLayer,
setActiveTool,
setGenerateDialog,
setImageContextMenu,
setMetadataLayer,
],
);
const pickCharacterSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
assignCharacterSpecReference(currentDialog, layer),
);
setIsPickingCharacterSpecFromCanvas(false);
setIsCharacterSpecMenuOpen(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu],
);
const pickGenerationReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
appendGenerationReference(currentDialog, layer),
);
setIsPickingGenerationReferenceFromCanvas(false);
setIsGenerationReferenceMenuOpen(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu],
);
const pickCharacterReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
appendCharacterReference(currentDialog, layer),
);
setIsPickingCharacterReferenceFromCanvas(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu],
);
const pickIconSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
assignIconSpecReference(currentDialog, layer),
);
if (layer.assetKind !== 'icon-spec') {
return;
}
setIsPickingIconSpecFromCanvas(false);
setIsIconSpecMenuOpen(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu],
);
const pickUiDesignSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
assignUiDesignSpecReference(currentDialog, layer),
);
setIsPickingUiDesignSpecFromCanvas(false);
setIsUiDesignSpecMenuOpen(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu],
);
const updateIconDescription = useCallback(
(index: number, value: string) => {
setGenerateDialog((currentDialog) =>
updateIconDescriptionInDialog(currentDialog, index, value),
);
},
[setGenerateDialog],
);
const addIconDescription = useCallback(() => {
setGenerateDialog(appendIconDescriptionToDialog);
}, [setGenerateDialog]);
const generationSubmissionWorkflow = useImageCanvasGenerationSubmissionWorkflow({
layers,
canvasSize,
viewport,
layerCounterRef,
quickEditPanel,
quickEditSourceLayer,
setQuickEditPanel,
characterAnimationPanel,
characterAnimationSourceLayer,
setCharacterAnimationPanel,
setGenerateDialog,
updateCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
fitLayers,
setActiveTool,
setActiveSidebarPanel,
rememberImageModel: setLastImageModel,
persistGeneratedAsset,
});
const {
submitCharacterAnimation,
submitIconSpritesheetGeneration,
submitImageGeneration,
submitQuickEdit,
} = generationSubmissionWorkflow;
const updateSpecFormValue = useCallback(
(key: keyof SpecFormValues, value: string) => {
setGenerateDialog((currentDialog) =>
updateSpecFormDialogValue(currentDialog, key, value),
);
},
[setGenerateDialog],
);
const updateCharacterAnimationDuration = useCallback(
(frameCountValue: string) => {
setCharacterAnimationPanel((currentPanel) =>
updateCharacterAnimationDurationPanel(currentPanel, frameCountValue),
);
},
[],
);
const hideGeneratedLayerPanelAfterBlur = useCallback(() => {
setGenerateDialog((currentDialog) =>
hideGeneratedLayerComposerAfterBlur(currentDialog),
);
}, [setGenerateDialog]);
const closeGenerateComposer = useCallback(() => {
setGenerateDialog(closeGenerateComposerDialog);
setActiveTool('select');
}, [setActiveTool, setGenerateDialog]);
const clearDeletedLayerGenerationState = useCallback(
(targetLayerId: string) => {
setQuickEditPanel((currentPanel) =>
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
);
setCharacterAnimationPanel((currentPanel) =>
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'edit' &&
currentDialog.sourceLayerId === targetLayerId
? null
: currentDialog,
);
removeCanvasGenerationDialogsByLayerId(targetLayerId);
},
[removeCanvasGenerationDialogsByLayerId, setGenerateDialog],
);
return useMemo(
() => ({
quickEditPanel,
setQuickEditPanel,
quickEditSourceLayer,
quickEditSizeOptions,
quickEditModelOptions,
characterAnimationPanel,
setCharacterAnimationPanel,
characterAnimationSourceLayer,
characterAnimationPrice,
iconDescriptionValues,
isSpecMenuOpen,
setIsSpecMenuOpen,
isGenerationReferenceMenuOpen,
setIsGenerationReferenceMenuOpen,
isCharacterSpecMenuOpen,
setIsCharacterSpecMenuOpen,
isCharacterReferenceMenuOpen,
setIsCharacterReferenceMenuOpen,
isPickingGenerationReferenceFromCanvas,
setIsPickingGenerationReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
isIconSpecMenuOpen,
setIsIconSpecMenuOpen,
isPickingIconSpecFromCanvas,
setIsPickingIconSpecFromCanvas,
isUiDesignSpecMenuOpen,
setIsUiDesignSpecMenuOpen,
isPickingUiDesignSpecFromCanvas,
setIsPickingUiDesignSpecFromCanvas,
openGenerateDialog,
openSpecDialog,
openCharacterAnimationPanel,
openCharacterGenerationDialog,
openIconGenerationDialog,
openVideoGenerationDialog,
openUiDesignGenerationDialog,
openEditDialog,
openQuickEditPanel,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
submitIconSpritesheetGeneration,
submitQuickEdit,
submitImageGeneration,
updateSpecFormValue,
updateIconDescription,
addIconDescription,
updateCharacterAnimationDuration,
rememberImageModel: setLastImageModel,
submitCharacterAnimation,
hideGeneratedLayerPanelAfterBlur,
closeGenerateComposer,
clearDeletedLayerGenerationState,
}),
[
addIconDescription,
characterAnimationPanel,
characterAnimationPrice,
characterAnimationSourceLayer,
clearDeletedLayerGenerationState,
closeGenerateComposer,
hideGeneratedLayerPanelAfterBlur,
iconDescriptionValues,
isCharacterReferenceMenuOpen,
isCharacterSpecMenuOpen,
isIconSpecMenuOpen,
isGenerationReferenceMenuOpen,
isPickingCharacterReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
isPickingGenerationReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingUiDesignSpecFromCanvas,
isUiDesignSpecMenuOpen,
isSpecMenuOpen,
openCharacterAnimationPanel,
openCharacterGenerationDialog,
openEditDialog,
openGenerateDialog,
openIconGenerationDialog,
openUiDesignGenerationDialog,
openQuickEditPanel,
openSpecDialog,
openVideoGenerationDialog,
pickCharacterReferenceFromLayer,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
quickEditModelOptions,
quickEditPanel,
quickEditSizeOptions,
quickEditSourceLayer,
submitCharacterAnimation,
submitIconSpritesheetGeneration,
submitImageGeneration,
submitQuickEdit,
updateCharacterAnimationDuration,
updateIconDescription,
updateSpecFormValue,
],
);
}