55469a2198
Require an explicit red-box selection before extracting UI-design assets: add rectangular/ellipse/brush selection UI, render red-outline marks into a marked Data URL on the client, and update the extraction prompt to "仅提取被红色框框选的素材并整理成spritesheet". Add frontend overlay/raster models and tests to support the selection flow and adjust numerous image-editor tests and views to wire up the selection lifecycle. On the dev ops side, enhance scripts/dev to detect a local %LOCALAPPDATA%/Genarrative/ffmpeg/bin installation on Windows, prepend it to PATH and inject CHARACTER_ANIMATION_FFMPEG_PATH/FFPROBE_PATH (with tests), and export the helper. Server config now prefers GENARRATIVE_*-prefixed FFmpeg env vars and includes a Rust test to validate reading the prefixed variables. Update docs to describe the new UI extraction behavior and local FFmpeg handling.
1250 lines
39 KiB
TypeScript
1250 lines
39 KiB
TypeScript
import {
|
|
type Dispatch,
|
|
type MutableRefObject,
|
|
type SetStateAction,
|
|
useCallback,
|
|
useMemo,
|
|
} from 'react';
|
|
|
|
import {
|
|
resolveEditorImageReferenceDataUrl,
|
|
resolveEditorImageReferenceDataUrlForGeneration,
|
|
} from '../../services/image-editor/editorImageReference';
|
|
import type {
|
|
EditorAssetSnapshot,
|
|
EditorProjectSnapshot,
|
|
} from '../../services/image-editor/editorProjectClient';
|
|
import {
|
|
editEditorImage,
|
|
extractEditorUiDesignAssets,
|
|
generateEditorBackgroundMusic,
|
|
generateEditorCharacterAnimation,
|
|
generateEditorIconSpritesheet,
|
|
generateEditorImage,
|
|
generateEditorSoundEffect,
|
|
generateEditorVideo,
|
|
} from '../../services/image-editor/editorProjectClient';
|
|
import type {
|
|
CanvasGenerationDialogState,
|
|
CanvasGenerationInputs,
|
|
CanvasLayer,
|
|
CanvasTool,
|
|
CanvasViewport,
|
|
CharacterAnimationPanelState,
|
|
GenerateDialogState,
|
|
QuickEditPanelState,
|
|
SidebarPanel,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { createQuickEditGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
|
|
import {
|
|
createAudioResultLayer,
|
|
createCharacterAnimationResultLayer,
|
|
createGeneratedResultLayer,
|
|
createIconSpritesheetResultLayers,
|
|
createQuickEditResultLayer,
|
|
createVideoResultLayer,
|
|
} from './ImageCanvasGenerationLayerModel';
|
|
import {
|
|
buildCharacterAnimationGenerationInputs,
|
|
buildEditGenerationInputs,
|
|
buildQuickEditGenerationInputs,
|
|
buildUiDesignAssetExtractionGenerationInputs,
|
|
buildVideoGenerationInputs,
|
|
calculateCharacterAnimationPrice,
|
|
calculateEditorVideoPrice,
|
|
CHARACTER_ANIMATION_MODEL,
|
|
DEFAULT_VIDEO_MODEL,
|
|
ICON_FRAME_DISPLAY_SIZE,
|
|
ICON_FRAME_ORIGINAL_SIZE,
|
|
inferEditorImageAspectRatio,
|
|
inferEditorImageSizeLabel,
|
|
isCanvasGenerationDialog,
|
|
normalizeEditorImageModel,
|
|
normalizeQuickEditPromptSourceReference,
|
|
resolveCharacterAnimationSourceImageSrc,
|
|
resolveEditorImageGenerationPixelSize,
|
|
resolveImageGenerationErrorMessage,
|
|
} from './ImageCanvasGenerationModel';
|
|
import {
|
|
buildCharacterAnimationSubmissionPlan,
|
|
buildIconSpritesheetGenerationSubmissionPlan,
|
|
buildImageGenerationSubmissionPlan,
|
|
} from './ImageCanvasGenerationSubmissionModel';
|
|
import type {
|
|
UiAssetExtractionMark,
|
|
UiAssetExtractionState,
|
|
} from './ImageCanvasUiAssetExtractionModel';
|
|
import { renderUiDesignAssetExtractionMarkedImage } from './ImageCanvasUiAssetExtractionRasterModel';
|
|
|
|
type CanvasSize = { width: number; height: number };
|
|
|
|
type CanvasGenerationDialogUpdater = (
|
|
dialog: CanvasGenerationDialogState,
|
|
) => CanvasGenerationDialogState | null;
|
|
|
|
type UiDesignAssetExtractionOptions = {
|
|
marks?: UiAssetExtractionMark[];
|
|
suppressAlert?: boolean;
|
|
};
|
|
|
|
type GenerationSubmissionWorkflowOptions = {
|
|
layers: CanvasLayer[];
|
|
canvasSize: CanvasSize;
|
|
viewport: CanvasViewport;
|
|
layerCounterRef: MutableRefObject<number>;
|
|
quickEditPanel: QuickEditPanelState | null;
|
|
quickEditSourceLayer: CanvasLayer | null;
|
|
quickEditSelectionState: UiAssetExtractionState | null;
|
|
setQuickEditPanel: Dispatch<SetStateAction<QuickEditPanelState | null>>;
|
|
characterAnimationPanel: CharacterAnimationPanelState | null;
|
|
characterAnimationDialog: CanvasGenerationDialogState | null;
|
|
characterAnimationSourceLayer: CanvasLayer | null;
|
|
setCharacterAnimationPanel: Dispatch<
|
|
SetStateAction<CharacterAnimationPanelState | null>
|
|
>;
|
|
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
|
openCanvasGenerationDialog: (
|
|
dialog: Omit<CanvasGenerationDialogState, 'id'>,
|
|
) => string;
|
|
updateCanvasGenerationDialogById: (
|
|
dialogId: string,
|
|
updater: CanvasGenerationDialogUpdater,
|
|
) => void;
|
|
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
|
|
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>>;
|
|
rememberImageModel: (imageModel: string) => void;
|
|
projectId?: string | null;
|
|
assetFolderId?: string | null;
|
|
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
|
|
applyProjectSnapshot?: (project: EditorProjectSnapshot) => void;
|
|
};
|
|
|
|
async function normalizePublicationReferenceImages(
|
|
input: Parameters<typeof generateEditorImage>[0],
|
|
) {
|
|
if (
|
|
input.kind !== 'publication-material' ||
|
|
!input.referenceImageSrcs?.length
|
|
) {
|
|
return input;
|
|
}
|
|
|
|
return {
|
|
...input,
|
|
referenceImageSrcs: await Promise.all(
|
|
input.referenceImageSrcs.map((referenceImageSrc) =>
|
|
resolveEditorImageReferenceDataUrlForGeneration(referenceImageSrc),
|
|
),
|
|
),
|
|
};
|
|
}
|
|
|
|
function isVideoQuickEditSource(layer: CanvasLayer) {
|
|
return layer.mediaType === 'video' || layer.assetKind === 'video';
|
|
}
|
|
|
|
function isCharacterAnimationQuickEditSource(layer: CanvasLayer) {
|
|
return layer.assetKind === 'character-animation';
|
|
}
|
|
|
|
export function useImageCanvasGenerationSubmissionWorkflow({
|
|
layers,
|
|
canvasSize,
|
|
viewport,
|
|
layerCounterRef,
|
|
quickEditPanel,
|
|
quickEditSourceLayer,
|
|
quickEditSelectionState,
|
|
setQuickEditPanel,
|
|
characterAnimationPanel,
|
|
characterAnimationDialog,
|
|
characterAnimationSourceLayer,
|
|
setCharacterAnimationPanel,
|
|
setGenerateDialog,
|
|
openCanvasGenerationDialog,
|
|
updateCanvasGenerationDialogById,
|
|
hasCanvasGenerationDialogById,
|
|
getGeneratingDialogPlaceholder,
|
|
appendCanvasLayersWithResources,
|
|
selectSingleLayer,
|
|
fitLayers,
|
|
setActiveTool,
|
|
setActiveSidebarPanel,
|
|
rememberImageModel,
|
|
projectId,
|
|
assetFolderId,
|
|
upsertGeneratedAsset,
|
|
applyProjectSnapshot,
|
|
}: GenerationSubmissionWorkflowOptions) {
|
|
const addGeneratedLayersToCanvas = useCallback(
|
|
(nextLayers: CanvasLayer[]) => {
|
|
appendCanvasLayersWithResources(nextLayers);
|
|
nextLayers.forEach((layer) => {
|
|
const asset = layer.generatedAssetSnapshot;
|
|
if (asset) {
|
|
upsertGeneratedAsset?.(asset);
|
|
}
|
|
});
|
|
},
|
|
[appendCanvasLayersWithResources, upsertGeneratedAsset],
|
|
);
|
|
|
|
const addGeneratedResultLayer = useCallback(
|
|
(
|
|
generated: Parameters<typeof createGeneratedResultLayer>[0]['generated'],
|
|
options: {
|
|
sourceLayer?: CanvasLayer;
|
|
frame?: GenerateDialogState['placeholder'];
|
|
assetKind?: CanvasLayer['assetKind'];
|
|
title?: string;
|
|
dialogId?: string;
|
|
generationInputs?: CanvasGenerationInputs;
|
|
} = {},
|
|
) => {
|
|
if (options.dialogId && !hasCanvasGenerationDialogById(options.dialogId)) {
|
|
return;
|
|
}
|
|
layerCounterRef.current += 1;
|
|
const generatedIndex = layerCounterRef.current;
|
|
const nextLayer = createGeneratedResultLayer({
|
|
generated,
|
|
generatedIndex,
|
|
canvasSize,
|
|
viewport,
|
|
sourceLayer: options.sourceLayer,
|
|
frame: options.frame,
|
|
assetKind: options.assetKind,
|
|
title: options.title,
|
|
generationInputs: options.generationInputs,
|
|
});
|
|
|
|
addGeneratedLayersToCanvas([nextLayer]);
|
|
selectSingleLayer(nextLayer.id);
|
|
setActiveSidebarPanel('layers');
|
|
if (options.sourceLayer) {
|
|
setGenerateDialog(null);
|
|
setActiveTool('select');
|
|
} else if (options.dialogId) {
|
|
updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => ({
|
|
...currentDialog,
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generatedLayerId: nextLayer.id,
|
|
errorMessage: undefined,
|
|
}));
|
|
}
|
|
if (options.sourceLayer) {
|
|
fitLayers([options.sourceLayer, nextLayer]);
|
|
}
|
|
},
|
|
[
|
|
addGeneratedLayersToCanvas,
|
|
canvasSize,
|
|
fitLayers,
|
|
layerCounterRef,
|
|
selectSingleLayer,
|
|
setActiveSidebarPanel,
|
|
setActiveTool,
|
|
setGenerateDialog,
|
|
updateCanvasGenerationDialogById,
|
|
hasCanvasGenerationDialogById,
|
|
viewport,
|
|
],
|
|
);
|
|
|
|
const addQuickEditResultLayer = useCallback(
|
|
(
|
|
generated: Parameters<typeof createQuickEditResultLayer>[0]['generated'],
|
|
sourceLayer: CanvasLayer,
|
|
generationInputs: CanvasGenerationInputs,
|
|
mode: QuickEditPanelState['mode'] = 'quick-edit',
|
|
options: {
|
|
frame?: GenerateDialogState['placeholder'];
|
|
dialogId?: string;
|
|
} = {},
|
|
) => {
|
|
if (options.dialogId && !hasCanvasGenerationDialogById(options.dialogId)) {
|
|
return;
|
|
}
|
|
layerCounterRef.current += 1;
|
|
const generatedIndex = layerCounterRef.current;
|
|
const nextLayer = createQuickEditResultLayer({
|
|
generated,
|
|
generatedIndex,
|
|
sourceLayer,
|
|
generationInputs,
|
|
mode,
|
|
frame: options.frame,
|
|
});
|
|
|
|
addGeneratedLayersToCanvas([nextLayer]);
|
|
selectSingleLayer(mode === 'redraw' ? sourceLayer.id : nextLayer.id);
|
|
setActiveSidebarPanel('layers');
|
|
if (mode === 'redraw') {
|
|
setQuickEditPanel((currentPanel) =>
|
|
currentPanel?.sourceLayerId === sourceLayer.id
|
|
? {
|
|
...currentPanel,
|
|
mode: 'redraw',
|
|
status: 'idle',
|
|
errorMessage: undefined,
|
|
}
|
|
: currentPanel,
|
|
);
|
|
} else {
|
|
setQuickEditPanel(null);
|
|
setActiveTool('select');
|
|
if (options.dialogId) {
|
|
updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => ({
|
|
...currentDialog,
|
|
status: 'idle',
|
|
composerOpen: false,
|
|
generatedLayerId: nextLayer.id,
|
|
errorMessage: undefined,
|
|
}));
|
|
}
|
|
}
|
|
fitLayers([sourceLayer, nextLayer]);
|
|
},
|
|
[
|
|
addGeneratedLayersToCanvas,
|
|
fitLayers,
|
|
layerCounterRef,
|
|
selectSingleLayer,
|
|
setActiveSidebarPanel,
|
|
setActiveTool,
|
|
setQuickEditPanel,
|
|
updateCanvasGenerationDialogById,
|
|
hasCanvasGenerationDialogById,
|
|
],
|
|
);
|
|
|
|
const addIconSpritesheetResultLayers = useCallback(
|
|
(
|
|
generated: Parameters<
|
|
typeof createIconSpritesheetResultLayers
|
|
>[0]['generated'],
|
|
iconResults: Parameters<
|
|
typeof createIconSpritesheetResultLayers
|
|
>[0]['iconResults'],
|
|
generationInputs: CanvasGenerationInputs,
|
|
frame?: GenerateDialogState['placeholder'],
|
|
dialogId?: string,
|
|
options: {
|
|
spritesheetTitle?: string;
|
|
} = {},
|
|
) => {
|
|
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
|
|
return [];
|
|
}
|
|
const startIndex = layerCounterRef.current + 1;
|
|
const nextLayers = createIconSpritesheetResultLayers({
|
|
generated,
|
|
iconResults,
|
|
startIndex,
|
|
canvasSize,
|
|
viewport,
|
|
generationInputs,
|
|
frame,
|
|
spritesheetTitle: options.spritesheetTitle,
|
|
});
|
|
|
|
if (!nextLayers.length) {
|
|
return [];
|
|
}
|
|
layerCounterRef.current += nextLayers.length;
|
|
addGeneratedLayersToCanvas(nextLayers);
|
|
selectSingleLayer(nextLayers[0]?.id ?? null);
|
|
setActiveSidebarPanel('layers');
|
|
if (dialogId) {
|
|
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
|
|
...currentDialog,
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generatedLayerId: nextLayers[0]?.id,
|
|
errorMessage: undefined,
|
|
}));
|
|
}
|
|
setActiveTool('select');
|
|
return nextLayers;
|
|
},
|
|
[
|
|
addGeneratedLayersToCanvas,
|
|
canvasSize,
|
|
layerCounterRef,
|
|
selectSingleLayer,
|
|
setActiveSidebarPanel,
|
|
setActiveTool,
|
|
updateCanvasGenerationDialogById,
|
|
hasCanvasGenerationDialogById,
|
|
viewport,
|
|
],
|
|
);
|
|
|
|
const addVideoResultLayer = useCallback(
|
|
(
|
|
generated: Parameters<typeof createVideoResultLayer>[0]['generated'],
|
|
title: string,
|
|
generationInputs: CanvasGenerationInputs,
|
|
frame?: GenerateDialogState['placeholder'],
|
|
dialogId?: string,
|
|
options: {
|
|
sourceLayer?: CanvasLayer;
|
|
assetKind?: CanvasLayer['assetKind'];
|
|
} = {},
|
|
) => {
|
|
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
|
|
return;
|
|
}
|
|
layerCounterRef.current += 1;
|
|
const generatedIndex = layerCounterRef.current;
|
|
const nextLayer = createVideoResultLayer({
|
|
generated,
|
|
generatedIndex,
|
|
title,
|
|
canvasSize,
|
|
viewport,
|
|
generationInputs,
|
|
frame,
|
|
sourceLayer: options.sourceLayer,
|
|
assetKind: options.assetKind,
|
|
});
|
|
|
|
addGeneratedLayersToCanvas([nextLayer]);
|
|
selectSingleLayer(nextLayer.id);
|
|
setActiveSidebarPanel('layers');
|
|
if (dialogId) {
|
|
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
|
|
...currentDialog,
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generatedLayerId: nextLayer.id,
|
|
errorMessage: undefined,
|
|
}));
|
|
}
|
|
setActiveTool('select');
|
|
},
|
|
[
|
|
addGeneratedLayersToCanvas,
|
|
canvasSize,
|
|
layerCounterRef,
|
|
selectSingleLayer,
|
|
setActiveSidebarPanel,
|
|
setActiveTool,
|
|
updateCanvasGenerationDialogById,
|
|
hasCanvasGenerationDialogById,
|
|
viewport,
|
|
],
|
|
);
|
|
|
|
const addAudioResultLayer = useCallback(
|
|
(
|
|
generated: Parameters<typeof createAudioResultLayer>[0]['generated'],
|
|
title: string,
|
|
generationInputs: CanvasGenerationInputs,
|
|
frame?: GenerateDialogState['placeholder'],
|
|
dialogId?: string,
|
|
) => {
|
|
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
|
|
return;
|
|
}
|
|
layerCounterRef.current += 1;
|
|
const generatedIndex = layerCounterRef.current;
|
|
const nextLayer = createAudioResultLayer({
|
|
generated,
|
|
generatedIndex,
|
|
title,
|
|
canvasSize,
|
|
viewport,
|
|
generationInputs,
|
|
frame,
|
|
});
|
|
|
|
addGeneratedLayersToCanvas([nextLayer]);
|
|
selectSingleLayer(nextLayer.id);
|
|
setActiveSidebarPanel('layers');
|
|
if (dialogId) {
|
|
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
|
|
...currentDialog,
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generatedLayerId: nextLayer.id,
|
|
errorMessage: undefined,
|
|
}));
|
|
}
|
|
setActiveTool('select');
|
|
},
|
|
[
|
|
addGeneratedLayersToCanvas,
|
|
canvasSize,
|
|
layerCounterRef,
|
|
selectSingleLayer,
|
|
setActiveSidebarPanel,
|
|
setActiveTool,
|
|
updateCanvasGenerationDialogById,
|
|
hasCanvasGenerationDialogById,
|
|
viewport,
|
|
],
|
|
);
|
|
|
|
const addCharacterAnimationResultLayer = useCallback(
|
|
(
|
|
generated: Parameters<
|
|
typeof createCharacterAnimationResultLayer
|
|
>[0]['generated'],
|
|
title: string,
|
|
generationInputs: CanvasGenerationInputs,
|
|
frame?: GenerateDialogState['placeholder'],
|
|
dialogId?: string,
|
|
options: {
|
|
sourceLayer?: CanvasLayer;
|
|
} = {},
|
|
) => {
|
|
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
|
|
return;
|
|
}
|
|
layerCounterRef.current += 1;
|
|
const generatedIndex = layerCounterRef.current;
|
|
const nextLayer = createCharacterAnimationResultLayer({
|
|
generated,
|
|
generatedIndex,
|
|
title,
|
|
canvasSize,
|
|
viewport,
|
|
generationInputs,
|
|
frame,
|
|
sourceLayer: options.sourceLayer,
|
|
});
|
|
|
|
if (!nextLayer) {
|
|
return;
|
|
}
|
|
addGeneratedLayersToCanvas([nextLayer]);
|
|
selectSingleLayer(nextLayer.id);
|
|
setActiveSidebarPanel('layers');
|
|
if (dialogId) {
|
|
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
|
|
...currentDialog,
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generatedLayerId: nextLayer.id,
|
|
characterAnimationResult: generated,
|
|
errorMessage: undefined,
|
|
}));
|
|
}
|
|
setActiveTool('select');
|
|
},
|
|
[
|
|
addGeneratedLayersToCanvas,
|
|
canvasSize,
|
|
layerCounterRef,
|
|
selectSingleLayer,
|
|
setActiveSidebarPanel,
|
|
setActiveTool,
|
|
updateCanvasGenerationDialogById,
|
|
hasCanvasGenerationDialogById,
|
|
viewport,
|
|
],
|
|
);
|
|
|
|
const extractUiDesignAssets = useCallback(
|
|
async (
|
|
sourceLayer: CanvasLayer,
|
|
options: UiDesignAssetExtractionOptions = {},
|
|
) => {
|
|
if (sourceLayer.assetKind !== 'ui-design') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let sourceImageSrc = await resolveEditorImageReferenceDataUrl(
|
|
sourceLayer.objectKey?.trim() || sourceLayer.src,
|
|
);
|
|
if (options.marks?.length) {
|
|
sourceImageSrc = await renderUiDesignAssetExtractionMarkedImage({
|
|
source: sourceImageSrc,
|
|
marks: options.marks,
|
|
});
|
|
}
|
|
const generationInputs =
|
|
buildUiDesignAssetExtractionGenerationInputs(sourceLayer);
|
|
const generated = await extractEditorUiDesignAssets({
|
|
sourceImageSrc,
|
|
projectId,
|
|
generationInputs,
|
|
assetFolderId,
|
|
spritesheetLabel: `${sourceLayer.title} 素材图集`,
|
|
});
|
|
const extractionFrame = {
|
|
x: sourceLayer.x + sourceLayer.width + 32,
|
|
y: sourceLayer.y,
|
|
width: ICON_FRAME_DISPLAY_SIZE.width,
|
|
height: ICON_FRAME_DISPLAY_SIZE.height,
|
|
originalWidth: ICON_FRAME_ORIGINAL_SIZE.width,
|
|
originalHeight: ICON_FRAME_ORIGINAL_SIZE.height,
|
|
};
|
|
const nextLayers = addIconSpritesheetResultLayers(
|
|
generated,
|
|
generated.iconImageSrcs,
|
|
generationInputs,
|
|
extractionFrame,
|
|
undefined,
|
|
{
|
|
spritesheetTitle: `${sourceLayer.title} 素材图集`,
|
|
},
|
|
);
|
|
if (nextLayers.length) {
|
|
fitLayers([sourceLayer, ...nextLayers]);
|
|
}
|
|
} catch (error) {
|
|
if (options.suppressAlert) {
|
|
throw error;
|
|
}
|
|
window.alert(
|
|
error instanceof Error && error.message.trim()
|
|
? error.message
|
|
: '提取素材失败',
|
|
);
|
|
}
|
|
},
|
|
[addIconSpritesheetResultLayers, fitLayers, projectId, assetFolderId],
|
|
);
|
|
|
|
const submitIconSpritesheetGeneration = useCallback(
|
|
async (dialog: GenerateDialogState) => {
|
|
if (dialog.mode !== 'icon') {
|
|
return;
|
|
}
|
|
const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null;
|
|
const setSubmittingIconDialog = (
|
|
nextDialog: CanvasGenerationDialogState,
|
|
) => {
|
|
updateCanvasGenerationDialogById(nextDialog.id, () => nextDialog);
|
|
};
|
|
const submissionPlan =
|
|
buildIconSpritesheetGenerationSubmissionPlan(dialog);
|
|
if (submissionPlan.ok === false) {
|
|
if (canvasDialog) {
|
|
setSubmittingIconDialog({
|
|
...canvasDialog,
|
|
status: 'failed',
|
|
composerOpen: true,
|
|
errorMessage: submissionPlan.errorMessage,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!canvasDialog) {
|
|
return;
|
|
}
|
|
|
|
setSubmittingIconDialog({
|
|
...canvasDialog,
|
|
iconDescriptions: submissionPlan.iconDescriptions,
|
|
status: 'generating',
|
|
composerOpen: false,
|
|
errorMessage: undefined,
|
|
});
|
|
|
|
try {
|
|
const generated = await generateEditorIconSpritesheet({
|
|
...submissionPlan.input,
|
|
projectId,
|
|
generationInputs: submissionPlan.generationInputs,
|
|
assetFolderId,
|
|
});
|
|
rememberImageModel(submissionPlan.rememberImageModel);
|
|
addIconSpritesheetResultLayers(
|
|
generated,
|
|
generated.iconImageSrcs,
|
|
submissionPlan.generationInputs,
|
|
getGeneratingDialogPlaceholder(dialog),
|
|
canvasDialog.id,
|
|
);
|
|
} catch (error) {
|
|
setSubmittingIconDialog({
|
|
...canvasDialog,
|
|
iconDescriptions: submissionPlan.iconDescriptions,
|
|
status: 'failed',
|
|
composerOpen: true,
|
|
errorMessage: resolveImageGenerationErrorMessage(error),
|
|
});
|
|
}
|
|
},
|
|
[
|
|
addIconSpritesheetResultLayers,
|
|
getGeneratingDialogPlaceholder,
|
|
rememberImageModel,
|
|
projectId,
|
|
assetFolderId,
|
|
updateCanvasGenerationDialogById,
|
|
],
|
|
);
|
|
|
|
const submitQuickEdit = useCallback(async () => {
|
|
if (!quickEditPanel || !quickEditSourceLayer) {
|
|
return;
|
|
}
|
|
|
|
const panelMode = quickEditPanel.mode ?? 'quick-edit';
|
|
const quickEditReferences =
|
|
panelMode === 'redraw' ? [] : (quickEditPanel.quickEditReferences ?? []);
|
|
const sourceReferenceIndex = quickEditReferences.length + 1;
|
|
const quickEditAspectRatio =
|
|
quickEditPanel.aspectRatio ??
|
|
inferEditorImageAspectRatio(
|
|
quickEditSourceLayer.originalWidth,
|
|
quickEditSourceLayer.originalHeight,
|
|
);
|
|
const quickEditImageSize =
|
|
quickEditPanel.imageSize ??
|
|
inferEditorImageSizeLabel(
|
|
quickEditSourceLayer.originalWidth,
|
|
quickEditSourceLayer.originalHeight,
|
|
);
|
|
const quickEditOutputSize = resolveEditorImageGenerationPixelSize({
|
|
model: quickEditPanel.model,
|
|
aspectRatio: quickEditAspectRatio,
|
|
imageSize: quickEditImageSize,
|
|
});
|
|
const quickEditSize = `${quickEditOutputSize.width}x${quickEditOutputSize.height}`;
|
|
const basePrompt =
|
|
quickEditPanel.prompt.trim() ||
|
|
(panelMode === 'redraw' ? '重绘图片' : '快速编辑图片');
|
|
const normalizedPrompt =
|
|
panelMode === 'redraw'
|
|
? basePrompt
|
|
: normalizeQuickEditPromptSourceReference(
|
|
basePrompt,
|
|
sourceReferenceIndex,
|
|
);
|
|
let quickEditDialogId: string | undefined;
|
|
if (panelMode === 'quick-edit') {
|
|
quickEditDialogId = openCanvasGenerationDialog(
|
|
createQuickEditGenerationDialogDraft({
|
|
sourceLayer: quickEditSourceLayer,
|
|
prompt: normalizedPrompt,
|
|
status: 'generating',
|
|
references: quickEditReferences,
|
|
model: quickEditPanel.model,
|
|
aspectRatio: quickEditAspectRatio,
|
|
imageSize: quickEditImageSize,
|
|
frame: {
|
|
width: quickEditOutputSize.width,
|
|
height: quickEditOutputSize.height,
|
|
},
|
|
}),
|
|
);
|
|
setQuickEditPanel(null);
|
|
selectSingleLayer(null);
|
|
} else {
|
|
setQuickEditPanel({
|
|
...quickEditPanel,
|
|
aspectRatio: quickEditAspectRatio,
|
|
imageSize: quickEditImageSize,
|
|
size: quickEditSize,
|
|
prompt: normalizedPrompt,
|
|
status: 'generating',
|
|
errorMessage: undefined,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const extraReferenceImageSrcs = await Promise.all(
|
|
quickEditReferences.map((reference) =>
|
|
resolveEditorImageReferenceDataUrl(
|
|
reference.objectKey?.trim() || reference.src,
|
|
),
|
|
),
|
|
);
|
|
const quickEditFrame = quickEditDialogId
|
|
? getGeneratingDialogPlaceholder({
|
|
id: quickEditDialogId,
|
|
mode: 'quick-edit',
|
|
prompt: normalizedPrompt,
|
|
status: 'generating',
|
|
})
|
|
: undefined;
|
|
if (
|
|
panelMode === 'quick-edit' &&
|
|
isCharacterAnimationQuickEditSource(quickEditSourceLayer)
|
|
) {
|
|
const animationResolution = '480p' as const;
|
|
const animationDurationSeconds = 4 as const;
|
|
const animationFrameCount = 32 as const;
|
|
const generationInputs = buildCharacterAnimationGenerationInputs(
|
|
normalizedPrompt,
|
|
quickEditSourceLayer,
|
|
);
|
|
const generated = await generateEditorCharacterAnimation({
|
|
sourceLayerId: quickEditSourceLayer.id,
|
|
sourceImageSrc: resolveCharacterAnimationSourceImageSrc(
|
|
quickEditSourceLayer,
|
|
),
|
|
sourceWidth: quickEditSourceLayer.originalWidth,
|
|
sourceHeight: quickEditSourceLayer.originalHeight,
|
|
promptText: normalizedPrompt,
|
|
resolution: animationResolution,
|
|
ratio: 'same',
|
|
frameCount: animationFrameCount,
|
|
durationSeconds: animationDurationSeconds,
|
|
priceMudPoints: calculateCharacterAnimationPrice(
|
|
CHARACTER_ANIMATION_MODEL,
|
|
animationResolution,
|
|
animationDurationSeconds,
|
|
),
|
|
model: CHARACTER_ANIMATION_MODEL,
|
|
});
|
|
addCharacterAnimationResultLayer(
|
|
generated,
|
|
`${quickEditSourceLayer.title} 快速编辑`,
|
|
generationInputs,
|
|
quickEditFrame,
|
|
quickEditDialogId,
|
|
{
|
|
sourceLayer: quickEditSourceLayer,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
if (panelMode === 'quick-edit' && isVideoQuickEditSource(quickEditSourceLayer)) {
|
|
const videoModel = DEFAULT_VIDEO_MODEL;
|
|
const videoResolution = '480p' as const;
|
|
const videoDurationSeconds = 4 as const;
|
|
const sourceVideoSrc =
|
|
quickEditSourceLayer.objectKey?.trim() || quickEditSourceLayer.src;
|
|
const generationInputs = buildVideoGenerationInputs(
|
|
normalizedPrompt,
|
|
quickEditReferences,
|
|
);
|
|
const generated = await generateEditorVideo({
|
|
prompt: normalizedPrompt,
|
|
model: videoModel,
|
|
aspectRatio: '16:9',
|
|
durationSeconds: videoDurationSeconds,
|
|
resolution: videoResolution,
|
|
mode: 'std',
|
|
sound: 'off',
|
|
priceMudPoints: calculateEditorVideoPrice(
|
|
videoModel,
|
|
videoResolution,
|
|
videoDurationSeconds,
|
|
),
|
|
...(extraReferenceImageSrcs.length
|
|
? { referenceImageSrcs: extraReferenceImageSrcs }
|
|
: {}),
|
|
referenceVideoSrcs: [sourceVideoSrc],
|
|
});
|
|
addVideoResultLayer(
|
|
generated,
|
|
`${quickEditSourceLayer.title} 快速编辑`,
|
|
generationInputs,
|
|
quickEditFrame,
|
|
quickEditDialogId,
|
|
{
|
|
sourceLayer: quickEditSourceLayer,
|
|
assetKind: quickEditSourceLayer.assetKind,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
let sourceReferenceImageSrc = await resolveEditorImageReferenceDataUrl(
|
|
quickEditSourceLayer.objectKey?.trim() || quickEditSourceLayer.src,
|
|
);
|
|
const quickEditSelectionMarks =
|
|
panelMode === 'quick-edit' &&
|
|
quickEditSelectionState?.sourceLayerId === quickEditSourceLayer.id
|
|
? quickEditSelectionState.marks
|
|
: [];
|
|
if (quickEditSelectionMarks.length > 0) {
|
|
sourceReferenceImageSrc =
|
|
await renderUiDesignAssetExtractionMarkedImage({
|
|
source: sourceReferenceImageSrc,
|
|
marks: quickEditSelectionMarks,
|
|
showIndexLabels: true,
|
|
});
|
|
}
|
|
const generationInputs =
|
|
panelMode === 'redraw'
|
|
? buildEditGenerationInputs(
|
|
'重绘提示词',
|
|
normalizedPrompt,
|
|
quickEditSourceLayer,
|
|
)
|
|
: buildQuickEditGenerationInputs(
|
|
'快速编辑提示词',
|
|
normalizedPrompt,
|
|
quickEditSourceLayer,
|
|
quickEditReferences,
|
|
);
|
|
const isCharacterRedraw =
|
|
panelMode === 'redraw' && quickEditSourceLayer.assetKind === 'character';
|
|
const generated = await generateEditorImage({
|
|
prompt: normalizedPrompt,
|
|
size: quickEditSize,
|
|
aspectRatio: quickEditAspectRatio,
|
|
imageSize: quickEditImageSize,
|
|
kind: isCharacterRedraw ? 'character' : 'quick-edit',
|
|
model: normalizeEditorImageModel(quickEditPanel.model),
|
|
referenceImageSrcs: [
|
|
...extraReferenceImageSrcs,
|
|
sourceReferenceImageSrc,
|
|
],
|
|
projectId,
|
|
assetKind: quickEditSourceLayer.assetKind,
|
|
generationInputs,
|
|
assetFolderId,
|
|
assetLabel: `${quickEditSourceLayer.title} ${
|
|
panelMode === 'redraw' ? '重绘' : '快速编辑'
|
|
}`,
|
|
sourceResourceId: quickEditSourceLayer.resourceId,
|
|
});
|
|
addQuickEditResultLayer(
|
|
generated,
|
|
quickEditSourceLayer,
|
|
generationInputs,
|
|
panelMode,
|
|
quickEditDialogId
|
|
? {
|
|
frame: quickEditFrame,
|
|
dialogId: quickEditDialogId,
|
|
}
|
|
: {},
|
|
);
|
|
} catch (error) {
|
|
if (quickEditDialogId) {
|
|
updateCanvasGenerationDialogById(quickEditDialogId, (currentDialog) => ({
|
|
...currentDialog,
|
|
prompt: normalizedPrompt,
|
|
status: 'failed',
|
|
composerOpen: false,
|
|
errorMessage: resolveImageGenerationErrorMessage(error),
|
|
}));
|
|
}
|
|
setQuickEditPanel({
|
|
...quickEditPanel,
|
|
aspectRatio: quickEditAspectRatio,
|
|
imageSize: quickEditImageSize,
|
|
size: quickEditSize,
|
|
prompt: normalizedPrompt,
|
|
status: 'failed',
|
|
errorMessage: resolveImageGenerationErrorMessage(error),
|
|
});
|
|
}
|
|
}, [
|
|
addCharacterAnimationResultLayer,
|
|
addQuickEditResultLayer,
|
|
addVideoResultLayer,
|
|
getGeneratingDialogPlaceholder,
|
|
openCanvasGenerationDialog,
|
|
projectId,
|
|
assetFolderId,
|
|
quickEditPanel,
|
|
quickEditSourceLayer,
|
|
quickEditSelectionState,
|
|
selectSingleLayer,
|
|
setQuickEditPanel,
|
|
updateCanvasGenerationDialogById,
|
|
]);
|
|
|
|
const submitImageGeneration = useCallback(
|
|
async (dialog: GenerateDialogState) => {
|
|
const normalizedPrompt =
|
|
dialog.prompt.trim() ||
|
|
(dialog.mode === 'edit'
|
|
? '修改当前图片'
|
|
: dialog.mode === 'audio-sound-effect'
|
|
? '游戏音效'
|
|
: dialog.mode === 'audio-background-music'
|
|
? '游戏背景音乐'
|
|
: 'AI 生成图片');
|
|
const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null;
|
|
if (canvasDialog) {
|
|
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({
|
|
...currentDialog,
|
|
prompt: normalizedPrompt,
|
|
status: 'generating',
|
|
composerOpen: false,
|
|
}));
|
|
} else {
|
|
setGenerateDialog({
|
|
...dialog,
|
|
prompt: normalizedPrompt,
|
|
status: 'generating',
|
|
composerOpen: dialog.mode === 'edit',
|
|
});
|
|
}
|
|
|
|
try {
|
|
const submissionPlan = buildImageGenerationSubmissionPlan({
|
|
dialog,
|
|
layers,
|
|
nextGeneratedIndex: layerCounterRef.current + 1,
|
|
});
|
|
if (submissionPlan.kind === 'edit') {
|
|
const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
|
|
submissionPlan.sourceLayer.objectKey?.trim() ||
|
|
submissionPlan.sourceLayer.src,
|
|
);
|
|
const generated = await editEditorImage({
|
|
prompt: submissionPlan.normalizedPrompt,
|
|
sourceImageSrc: referenceImageSrc,
|
|
projectId,
|
|
assetKind: submissionPlan.sourceLayer.assetKind,
|
|
generationInputs: submissionPlan.generationInputs,
|
|
assetFolderId,
|
|
assetLabel: `${submissionPlan.sourceLayer.title} 修改结果`,
|
|
sourceResourceId: submissionPlan.sourceLayer.resourceId,
|
|
});
|
|
addGeneratedResultLayer(generated, {
|
|
sourceLayer: submissionPlan.sourceLayer,
|
|
generationInputs: submissionPlan.generationInputs,
|
|
});
|
|
} else if (submissionPlan.kind === 'video') {
|
|
const generated = await generateEditorVideo(submissionPlan.input);
|
|
addVideoResultLayer(
|
|
generated,
|
|
submissionPlan.result.title,
|
|
submissionPlan.result.generationInputs,
|
|
getGeneratingDialogPlaceholder(dialog),
|
|
canvasDialog?.id,
|
|
);
|
|
} else if (submissionPlan.kind === 'audio') {
|
|
const generated =
|
|
submissionPlan.audioKind === 'sound-effect'
|
|
? await generateEditorSoundEffect(submissionPlan.input)
|
|
: await generateEditorBackgroundMusic(submissionPlan.input);
|
|
addAudioResultLayer(
|
|
generated,
|
|
submissionPlan.result.title,
|
|
submissionPlan.result.generationInputs,
|
|
getGeneratingDialogPlaceholder(dialog),
|
|
canvasDialog?.id,
|
|
);
|
|
} else {
|
|
const imageGenerationInput =
|
|
submissionPlan.input.kind === 'publication-material'
|
|
? await normalizePublicationReferenceImages(submissionPlan.input)
|
|
: submissionPlan.input;
|
|
const canvasCompletionPlaceholder =
|
|
getGeneratingDialogPlaceholder(dialog);
|
|
const generated = await generateEditorImage({
|
|
...imageGenerationInput,
|
|
projectId,
|
|
assetKind: submissionPlan.result.assetKind,
|
|
generationInputs: submissionPlan.result.generationInputs,
|
|
assetFolderId,
|
|
assetLabel: submissionPlan.result.title,
|
|
...(canvasDialog && projectId && canvasCompletionPlaceholder
|
|
? {
|
|
canvasCompletion: {
|
|
dialogId: canvasDialog.id,
|
|
title:
|
|
submissionPlan.result.title ??
|
|
imageGenerationInput.prompt ??
|
|
'AI 生成图片',
|
|
placeholder: canvasCompletionPlaceholder,
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
if (submissionPlan.rememberImageModel) {
|
|
rememberImageModel(submissionPlan.rememberImageModel);
|
|
}
|
|
if (generated.project && applyProjectSnapshot) {
|
|
applyProjectSnapshot(generated.project);
|
|
if (generated.asset) {
|
|
upsertGeneratedAsset?.(generated.asset);
|
|
}
|
|
return;
|
|
}
|
|
if (canvasDialog && projectId) {
|
|
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) =>
|
|
currentDialog.generatedLayerId
|
|
? currentDialog
|
|
: {
|
|
...currentDialog,
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
errorMessage: undefined,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
addGeneratedResultLayer(generated, {
|
|
frame: getGeneratingDialogPlaceholder(dialog),
|
|
assetKind: submissionPlan.result.assetKind,
|
|
title: submissionPlan.result.title,
|
|
dialogId: canvasDialog?.id,
|
|
generationInputs: submissionPlan.result.generationInputs,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (canvasDialog) {
|
|
updateCanvasGenerationDialogById(canvasDialog.id, () => ({
|
|
...canvasDialog,
|
|
prompt: normalizedPrompt,
|
|
status: 'failed',
|
|
composerOpen: true,
|
|
errorMessage: resolveImageGenerationErrorMessage(error),
|
|
}));
|
|
} else {
|
|
setGenerateDialog({
|
|
...dialog,
|
|
prompt: normalizedPrompt,
|
|
status: 'failed',
|
|
composerOpen: true,
|
|
errorMessage: resolveImageGenerationErrorMessage(error),
|
|
});
|
|
}
|
|
}
|
|
},
|
|
[
|
|
addGeneratedResultLayer,
|
|
addAudioResultLayer,
|
|
addVideoResultLayer,
|
|
getGeneratingDialogPlaceholder,
|
|
layerCounterRef,
|
|
layers,
|
|
rememberImageModel,
|
|
projectId,
|
|
assetFolderId,
|
|
applyProjectSnapshot,
|
|
setGenerateDialog,
|
|
updateCanvasGenerationDialogById,
|
|
upsertGeneratedAsset,
|
|
],
|
|
);
|
|
|
|
const submitCharacterAnimation = useCallback(async () => {
|
|
if (!characterAnimationPanel || !characterAnimationSourceLayer) {
|
|
return;
|
|
}
|
|
const submissionPlan = buildCharacterAnimationSubmissionPlan({
|
|
panel: characterAnimationPanel,
|
|
sourceLayer: characterAnimationSourceLayer,
|
|
});
|
|
const canvasDialog = characterAnimationDialog;
|
|
if (canvasDialog?.mode === 'character-animation') {
|
|
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({
|
|
...currentDialog,
|
|
prompt: submissionPlan.promptText,
|
|
characterAnimationResolution: characterAnimationPanel.resolution,
|
|
characterAnimationRatio: characterAnimationPanel.ratio,
|
|
characterAnimationFrameCount: characterAnimationPanel.frameCount,
|
|
characterAnimationDurationSeconds:
|
|
characterAnimationPanel.durationSeconds,
|
|
characterAnimationResult: undefined,
|
|
status: 'generating',
|
|
composerOpen: false,
|
|
errorMessage: undefined,
|
|
}));
|
|
}
|
|
const nextPanel = {
|
|
...characterAnimationPanel,
|
|
promptText: submissionPlan.promptText,
|
|
status: 'generating' as const,
|
|
errorMessage: undefined,
|
|
result: undefined,
|
|
};
|
|
if (!canvasDialog) {
|
|
setCharacterAnimationPanel(nextPanel);
|
|
}
|
|
|
|
try {
|
|
const result = await generateEditorCharacterAnimation(
|
|
submissionPlan.input,
|
|
);
|
|
if (canvasDialog) {
|
|
addCharacterAnimationResultLayer(
|
|
result,
|
|
'角色动作',
|
|
buildCharacterAnimationGenerationInputs(
|
|
submissionPlan.promptText,
|
|
characterAnimationSourceLayer,
|
|
),
|
|
getGeneratingDialogPlaceholder(canvasDialog),
|
|
canvasDialog.id,
|
|
{
|
|
sourceLayer: characterAnimationSourceLayer,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
setCharacterAnimationPanel((currentPanel) =>
|
|
currentPanel
|
|
? {
|
|
...currentPanel,
|
|
status: 'completed',
|
|
result,
|
|
}
|
|
: currentPanel,
|
|
);
|
|
} catch (error) {
|
|
if (canvasDialog) {
|
|
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({
|
|
...currentDialog,
|
|
prompt: submissionPlan.promptText,
|
|
characterAnimationResolution: characterAnimationPanel.resolution,
|
|
characterAnimationRatio: characterAnimationPanel.ratio,
|
|
characterAnimationFrameCount: characterAnimationPanel.frameCount,
|
|
characterAnimationDurationSeconds:
|
|
characterAnimationPanel.durationSeconds,
|
|
status: 'failed',
|
|
composerOpen: true,
|
|
errorMessage:
|
|
error instanceof Error && error.message.trim()
|
|
? error.message
|
|
: '生成角色动画失败',
|
|
}));
|
|
return;
|
|
}
|
|
setCharacterAnimationPanel((currentPanel) =>
|
|
currentPanel
|
|
? {
|
|
...currentPanel,
|
|
status: 'failed',
|
|
errorMessage:
|
|
error instanceof Error && error.message.trim()
|
|
? error.message
|
|
: '生成角色动画失败',
|
|
}
|
|
: currentPanel,
|
|
);
|
|
}
|
|
}, [
|
|
addCharacterAnimationResultLayer,
|
|
characterAnimationDialog,
|
|
characterAnimationPanel,
|
|
characterAnimationSourceLayer,
|
|
getGeneratingDialogPlaceholder,
|
|
setCharacterAnimationPanel,
|
|
updateCanvasGenerationDialogById,
|
|
]);
|
|
|
|
return useMemo(
|
|
() => ({
|
|
submitIconSpritesheetGeneration,
|
|
extractUiDesignAssets,
|
|
submitQuickEdit,
|
|
submitImageGeneration,
|
|
submitCharacterAnimation,
|
|
}),
|
|
[
|
|
submitCharacterAnimation,
|
|
extractUiDesignAssets,
|
|
submitIconSpritesheetGeneration,
|
|
submitImageGeneration,
|
|
submitQuickEdit,
|
|
],
|
|
);
|
|
}
|