Browse Source

图片分割

feature/071
yun 2 weeks ago
parent
commit
33b759d3b3
  1. 56
      src/components/composer/ComposerModelConfig.tsx
  2. 132
      src/components/composer/NodeMultiAnglePanel.tsx
  3. 28
      src/components/nodes/GenerateImageNode.tsx
  4. 28
      src/components/nodes/ImageInputNode.tsx
  5. 70
      src/utils/__tests__/generationConfig.test.ts
  6. 58
      src/utils/derivedImageNodes.ts
  7. 38
      src/utils/generationConfig.ts
  8. 37
      src/utils/splitGridNodes.ts

56
src/components/composer/ComposerModelConfig.tsx

@ -118,6 +118,36 @@ function valueMatchesOption(optionValue: unknown, value: unknown): boolean {
return Object.is(optionValue, value) || String(optionValue).toLowerCase() === String(value ?? "").toLowerCase();
}
function buildModelSelectionSettingFields(
detail: PopiModelDetail,
currentConfig: GenerationNodeConfig,
nextConfig: GenerationNodeConfig
) {
const parameterFields = detail.parameters.map((parameter) => ({
source: "parameters",
name: parameter.name,
type: parameter.type,
label: parameter.label ?? parameter.name,
options: parameter.options ?? [],
defaultValue: parameter.default,
currentValue: currentConfig.parameters?.[parameter.name],
nextValue: nextConfig.parameters?.[parameter.name],
changed: !Object.is(currentConfig.parameters?.[parameter.name], nextConfig.parameters?.[parameter.name]),
}));
const extensionFields = normalizeExtensionFields(detail.extensionFields).map((field) => ({
source: "extraTaskParams",
name: field.name,
type: field.type,
label: field.label ?? field.name,
options: field.options ?? [],
defaultValue: field.default,
currentValue: currentConfig.extraTaskParams?.[field.name],
nextValue: nextConfig.extraTaskParams?.[field.name],
changed: !Object.is(currentConfig.extraTaskParams?.[field.name], nextConfig.extraTaskParams?.[field.name]),
}));
return [...parameterFields, ...extensionFields];
}
export function ComposerModelConfig({
activeCapability,
config,
@ -225,6 +255,19 @@ export function ComposerModelConfig({
}
if (modelSelectionRequestIdRef.current !== requestId) return;
if (!detail) {
console.info("[composer-model-select]", {
model: nextModel,
settingFields: [],
currentConfig: {
parameters: config.parameters ?? {},
extraTaskParams: config.extraTaskParams ?? {},
},
nextConfigValues: {
parameters: baseConfig.parameters,
extraTaskParams: baseConfig.extraTaskParams,
},
nextConfig: baseConfig,
});
onConfigChange(baseConfig);
onModelChangeComplete?.(baseConfig, null);
return;
@ -239,6 +282,19 @@ export function ComposerModelConfig({
parameters: config.parameters ?? {},
extraTaskParams: config.extraTaskParams ?? {},
}, detail);
console.info("[composer-model-select]", {
model: selectedModelWithInputs,
settingFields: buildModelSelectionSettingFields(detail, config, nextConfig),
currentConfig: {
parameters: config.parameters ?? {},
extraTaskParams: config.extraTaskParams ?? {},
},
nextConfigValues: {
parameters: nextConfig.parameters,
extraTaskParams: nextConfig.extraTaskParams,
},
nextConfig,
});
onConfigChange(nextConfig);
onModelChangeComplete?.(nextConfig, detail);
};

132
src/components/composer/NodeMultiAnglePanel.tsx

@ -11,8 +11,10 @@ import {
import { useI18n } from "@/i18n";
import { useModelStore } from "@/store/modelStore";
import { useWorkflowStore } from "@/store/workflowStore";
import type { NanoBananaNodeData, SelectedModel, WorkflowNode } from "@/types";
import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles";
import type { GenerateResponse, SelectedModel, WorkflowNode } from "@/types";
import { buildGenerateHeaders } from "@/store/utils/buildApiHeaders";
import { executePopiartTask } from "@/store/execution/popiartTaskClient";
import { createDerivedImageNode } from "@/utils/derivedImageNodes";
import { toSelectedModel } from "@/utils/selectedModel";
interface NodeMultiAnglePanelProps {
@ -21,38 +23,38 @@ interface NodeMultiAnglePanelProps {
onClose: () => void;
}
function buildMultiAngleNodeData(
sourceImage: string,
settings: MultiAngleSettings,
selectedModel: SelectedModel
): Partial<NanoBananaNodeData> {
type GeneratedImageResult = {
image: string;
previewImage?: string;
};
function normalizeGeneratedImage(value: unknown): GeneratedImageResult | null {
if (typeof value === "string" && value.length > 0) {
return { image: value };
}
if (!value || typeof value !== "object") return null;
const record = value as { url?: unknown; previewImg?: unknown };
if (typeof record.url !== "string" || record.url.length === 0) return null;
return {
inputImages: [sourceImage],
inputPrompt: "multi-angle image generation",
aspectRatio: "16:9",
resolution: "2K",
model: "nano-banana-pro",
selectedModel,
imageCount: 1,
parameters: { batchSize: 1 },
extraTaskParams: {
horizontal_angle: settings.horizontal,
vertical_angle: settings.vertical,
zoom: settings.zoom,
},
useGoogleSearch: false,
useImageSearch: false,
status: "idle",
error: null,
image: record.url,
previewImage: typeof record.previewImg === "string" ? record.previewImg : undefined,
};
}
function readFirstGeneratedImage(result: GenerateResponse): GeneratedImageResult | null {
if (Array.isArray(result.images) && result.images.length > 0) {
return normalizeGeneratedImage(result.images[0]);
}
return normalizeGeneratedImage(result.image);
}
export function NodeMultiAnglePanel({ node, imageSource, onClose }: NodeMultiAnglePanelProps) {
const { t } = useI18n();
const addNode = useWorkflowStore((state) => state.addNode);
const getNodeById = useWorkflowStore((state) => state.getNodeById);
const onConnect = useWorkflowStore((state) => state.onConnect);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const providerSettings = useWorkflowStore((state) => state.providerSettings);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const multiAngleModel = useModelStore((state) => state.byKind.multiAngle[0] ?? null);
const [settings, setSettings] = useState<MultiAngleSettings>({ ...DEFAULT_MULTI_ANGLE_SETTINGS });
@ -99,66 +101,56 @@ export function NodeMultiAnglePanel({ node, imageSource, onClose }: NodeMultiAng
: typeof node.style?.width === "number"
? node.style.width
: 300;
const newNodeId = addNode(
"nanoBanana",
const result = await executePopiartTask(
{
images: [imageSource],
prompt: "multi-angle image generation",
selectedModel,
parameters: {
batchSize: 1,
horizontal_angle: settings.horizontal,
vertical_angle: settings.vertical,
zoom: settings.zoom,
},
mediaType: "image",
},
{
headers: buildGenerateHeaders("popiserver", providerSettings),
}
);
const generatedImage = readFirstGeneratedImage(result);
if (!generatedImage) {
throw new Error(result.error || "Generation failed");
}
createDerivedImageNode({
sourceNodeId: node.id,
position: {
x: node.position.x + currentWidth + 220,
y: node.position.y,
},
buildMultiAngleNodeData(imageSource, settings, selectedModel)
);
onConnect({
source: node.id,
sourceHandle: SINGLE_OUTPUT_HANDLE_ID,
target: newNodeId,
targetHandle: SINGLE_INPUT_HANDLE_ID,
image: generatedImage.image,
previewImage: generatedImage.previewImage,
filename: `multi-angle-${Date.now()}.png`,
dimensions: null,
label: t("imageInput.multiAngle"),
operation: "multiAngle",
addNode,
onConnect,
selectSingleNode,
});
selectSingleNode(newNodeId);
onClose();
await regenerateNode(newNodeId);
const generatedNode = getNodeById(newNodeId);
const generatedData = generatedNode?.data as NanoBananaNodeData | undefined;
if (generatedData?.outputImage) {
const resultNodeId = addNode(
"smartImage",
{
x: node.position.x + currentWidth + 560,
y: node.position.y,
},
{
image: generatedData.outputImage,
imageRef: undefined,
previewImage: generatedData.previewImg,
filename: `multi-angle-${Date.now()}.png`,
dimensions: null,
label: t("imageInput.multiAngle"),
derivedImage: {
sourceNodeId: newNodeId,
operation: "multiAngle",
},
disablePreviewModal: true,
}
);
onConnect({
source: newNodeId,
sourceHandle: SINGLE_OUTPUT_HANDLE_ID,
target: resultNodeId,
targetHandle: SINGLE_INPUT_HANDLE_ID,
});
selectSingleNode(resultNodeId);
}
} finally {
setIsGenerating(false);
}
}, [
addNode,
getNodeById,
imageSource,
isGenerating,
node,
onClose,
onConnect,
regenerateNode,
providerSettings,
selectSingleNode,
selectedModel,
settings,

28
src/components/nodes/GenerateImageNode.tsx

@ -36,6 +36,7 @@ import { createCroppedImageBlob } from "@/components/media/imageCropUtils";
import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { SplitGridCapsuleMenu, type SplitGridSelection } from "@/components/media/SplitGridCapsuleMenu";
import { createSplitGridImageNodes } from "@/utils/splitGridNodes";
import { createDerivedImageNode } from "@/utils/derivedImageNodes";
import { SplitGridSelectionOverlay } from "@/components/media/SplitGridSelectionOverlay";
import { SplitGridSelectionToolbar } from "@/components/media/SplitGridSelectionToolbar";
@ -220,29 +221,22 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
: typeof currentNode?.style?.width === "number"
? currentNode.style.width
: 300;
const nextNodeId = addNode("smartImage", {
x: currentPosition.x + currentWidth + 220,
y: currentPosition.y,
}, {
createDerivedImageNode({
sourceNodeId: id,
position: {
x: currentPosition.x + currentWidth + 220,
y: currentPosition.y,
},
image: uploaded.url,
imageRef: undefined,
previewImage: uploaded.previewUrl,
filename,
dimensions: { width: payload.outputWidth, height: payload.outputHeight },
label: t("imageInput.crop"),
derivedImage: {
sourceNodeId: id,
operation: "crop",
},
disablePreviewModal: true,
});
onConnect({
source: id,
sourceHandle: SINGLE_OUTPUT_HANDLE_ID,
target: nextNodeId,
targetHandle: SINGLE_INPUT_HANDLE_ID,
operation: "crop",
addNode,
onConnect,
selectSingleNode,
});
selectSingleNode(nextNodeId);
setIsCropping(false);
} catch (error) {
alert(error instanceof Error ? error.message : t("imageInput.cropFailed"));

28
src/components/nodes/ImageInputNode.tsx

@ -27,6 +27,7 @@ import { CropRatioSelect, MEDIA_EDIT_TOOL, MediaEditToolbar, type MediaEditTool,
import { createCroppedImageBlob } from "@/components/media/imageCropUtils";
import { SplitGridCapsuleMenu, type SplitGridSelection } from "@/components/media/SplitGridCapsuleMenu";
import { createSplitGridImageNodes } from "@/utils/splitGridNodes";
import { createDerivedImageNode } from "@/utils/derivedImageNodes";
import { SplitGridSelectionOverlay } from "@/components/media/SplitGridSelectionOverlay";
import { SplitGridSelectionToolbar } from "@/components/media/SplitGridSelectionToolbar";
@ -326,29 +327,22 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal
: typeof currentNode?.style?.width === "number"
? currentNode.style.width
: IMAGE_INPUT_DIMENSIONS.width;
const nextNodeId = addNode("smartImage", {
x: currentPosition.x + currentWidth + 220,
y: currentPosition.y,
}, {
createDerivedImageNode({
sourceNodeId: id,
position: {
x: currentPosition.x + currentWidth + 220,
y: currentPosition.y,
},
image: uploaded.url,
imageRef: undefined,
previewImage: uploaded.previewUrl,
filename,
dimensions: { width: payload.outputWidth, height: payload.outputHeight },
label: t("imageInput.crop"),
derivedImage: {
sourceNodeId: id,
operation: "crop",
},
disablePreviewModal: true,
});
onConnect({
source: id,
sourceHandle: SINGLE_OUTPUT_HANDLE_ID,
target: nextNodeId,
targetHandle: SINGLE_INPUT_HANDLE_ID,
operation: "crop",
addNode,
onConnect,
selectSingleNode,
});
selectSingleNode(nextNodeId);
setIsCropping(false);
} catch (error) {
alert(error instanceof Error ? error.message : t("imageInput.cropFailed"));

70
src/utils/__tests__/generationConfig.test.ts

@ -179,6 +179,76 @@ describe("generationConfig", () => {
});
});
it("ignores original model switch values that are not in schema options", () => {
const draft = buildGenerationConfigDraftFromModelSchema(
{
prompt: "prompt",
selectedModel: defaultModel,
inputImages: [],
imageCount: 1,
referenceSubjectList: [],
parameters: {
ratio: "4:3",
resolution: "520P",
duration: 10,
},
extraTaskParams: {
quality: "legacy",
},
},
{
parameters: [
{
name: "ratio",
type: "aspectRatio",
default: "16:9",
options: [
{ label: "16:9", value: "16:9" },
{ label: "9:16", value: "9:16" },
],
},
{
name: "resolution",
type: "string",
default: "720P",
options: [
{ label: "720P", value: "720P" },
{ label: "1K", value: "1K" },
],
},
{
name: "duration",
type: "integer",
default: 6,
options: [
{ label: "5秒", value: 5 },
{ label: "8秒", value: 8 },
],
},
],
extensionFields: [
{
name: "quality",
default: "high",
options: [
{ label: "High", value: "high" },
{ label: "Ultra", value: "ultra" },
],
},
],
}
);
expect(draft.parameters).toEqual({
ratio: "16:9",
resolution: "720P",
duration: 5,
});
expect(draft.extraTaskParams).toEqual({
quality: "high",
});
});
it("builds submittable configs by removing unsupported and empty schema values", () => {
const config = buildSubmittableGenerationConfig(
{

58
src/utils/derivedImageNodes.ts

@ -0,0 +1,58 @@
import type { Connection, XYPosition } from "@xyflow/react";
import type { SmartImageNodeData } from "@/types";
import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles";
export type DerivedImageOperation = NonNullable<SmartImageNodeData["derivedImage"]>["operation"];
export interface CreateDerivedImageNodeOptions {
sourceNodeId: string;
position: XYPosition;
image: string;
previewImage?: string;
filename: string;
dimensions?: { width: number; height: number } | null;
label: string;
operation: DerivedImageOperation;
addNode: (type: "smartImage", position: XYPosition, initialData?: Partial<SmartImageNodeData>) => string;
onConnect: (connection: Connection) => void;
selectSingleNode?: (nodeId: string) => void;
}
export function createDerivedImageNode({
sourceNodeId,
position,
image,
previewImage,
filename,
dimensions = null,
label,
operation,
addNode,
onConnect,
selectSingleNode,
}: CreateDerivedImageNodeOptions): string {
const nodeId = addNode("smartImage", position, {
image,
imageRef: undefined,
previewImage,
assetId: undefined,
filename,
dimensions,
label,
derivedImage: {
sourceNodeId,
operation,
},
disablePreviewModal: true,
});
onConnect({
source: sourceNodeId,
sourceHandle: SINGLE_OUTPUT_HANDLE_ID,
target: nodeId,
targetHandle: SINGLE_INPUT_HANDLE_ID,
});
selectSingleNode?.(nodeId);
return nodeId;
}

38
src/utils/generationConfig.ts

@ -122,6 +122,32 @@ function hasOwnNonEmptyParameterValue(parameters: Record<string, unknown>, name:
return hasOwnParameterValue(parameters, name) && hasParameterValue(parameters[name]);
}
function optionValueMatches(optionValue: unknown, value: unknown): boolean {
return Object.is(optionValue, value) || String(optionValue).toLowerCase() === String(value ?? "").toLowerCase();
}
function valueAllowedByOptions(
options: Array<{ value: unknown }> | undefined,
value: unknown
): boolean {
if (!options || options.length === 0) return true;
return options.some((option) => optionValueMatches(option.value, value));
}
function draftValueFromSchemaOptions(
currentValue: unknown,
defaultValue: unknown,
options: Array<{ value: unknown }> | undefined
): unknown {
if (hasParameterValue(currentValue) && valueAllowedByOptions(options, currentValue)) {
return currentValue;
}
if (hasParameterValue(defaultValue) && valueAllowedByOptions(options, defaultValue)) {
return defaultValue;
}
return options?.[0]?.value ?? null;
}
export function buildParameterDraftFromSchema(
currentParameters: Record<string, unknown> | undefined,
schema: ModelParameter[]
@ -130,7 +156,11 @@ export function buildParameterDraftFromSchema(
return Object.fromEntries(
schema.map((parameter) => [
parameter.name,
hasOwnNonEmptyParameterValue(source, parameter.name) ? source[parameter.name] : parameter.default ?? null,
draftValueFromSchemaOptions(
hasOwnNonEmptyParameterValue(source, parameter.name) ? source[parameter.name] : undefined,
parameter.default,
parameter.options
),
])
);
}
@ -160,7 +190,11 @@ export function buildExtraTaskParamDraftFromSchema(
return Object.fromEntries(
fields.map((field) => [
field.name,
hasOwnNonEmptyParameterValue(source, field.name) ? source[field.name] : field.default,
draftValueFromSchemaOptions(
hasOwnNonEmptyParameterValue(source, field.name) ? source[field.name] : undefined,
field.default,
field.options
),
])
);
}

37
src/utils/splitGridNodes.ts

@ -3,7 +3,7 @@ import type { Connection } from "@xyflow/react";
import type { SmartImageNodeData, WorkflowNode, WorkflowNodeData } from "@/types";
import { defaultNodeDimensions } from "@/constants/nodeDimensions";
import { splitSelectedWithDimensions } from "@/utils/gridSplitter";
import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles";
import { createDerivedImageNode } from "@/utils/derivedImageNodes";
interface SplitGridNodeCreationOptions {
sourceNode: WorkflowNode;
@ -69,34 +69,21 @@ export async function createSplitGridImageNodes({
if (!selectedCell) return;
const rowIndex = Math.floor(index / Math.max(1, Math.ceil(Math.sqrt(images.length))));
const colIndex = index % Math.max(1, Math.ceil(Math.sqrt(images.length)));
const nodeId = addNode(
"smartImage",
{
const nodeId = createDerivedImageNode({
sourceNodeId: sourceNode.id,
position: {
x: start.x + colIndex * (nodeWidth + gap),
y: start.y + rowIndex * (nodeHeight + gap),
},
{
image,
imageRef: undefined,
previewImage: undefined,
assetId: undefined,
filename: `split-${selectedCell.row}-${selectedCell.col}.png`,
dimensions: null,
label: `${selectedCell.row}-${selectedCell.col}`,
derivedImage: {
sourceNodeId: sourceNode.id,
operation: "splitGrid",
},
disablePreviewModal: true,
}
);
createdNodeIds.push(nodeId);
onConnect({
source: sourceNode.id,
sourceHandle: SINGLE_OUTPUT_HANDLE_ID,
target: nodeId,
targetHandle: SINGLE_INPUT_HANDLE_ID,
image,
filename: `split-${selectedCell.row}-${selectedCell.col}.png`,
dimensions: null,
label: `${selectedCell.row}-${selectedCell.col}`,
operation: "splitGrid",
addNode,
onConnect,
});
createdNodeIds.push(nodeId);
void readImageDimensions(image).then((dimensions) => {
if (!dimensions) return;

Loading…
Cancel
Save