You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

201 lines
6.7 KiB

"use client";
import { useCallback, useMemo, useState } from "react";
import {
DEFAULT_MULTI_ANGLE_SETTINGS,
MULTI_ANGLE_PRESET_SETTINGS,
MultiAngleEditor,
type MultiAnglePresetId,
type MultiAngleSettings,
} from "@/components/MultiAngleEditor";
import { useI18n } from "@/i18n";
import { useModelStore } from "@/store/modelStore";
import { useWorkflowStore } from "@/store/workflowStore";
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 {
node: WorkflowNode;
imageSource: string;
onClose: () => void;
}
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 {
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 onConnect = useWorkflowStore((state) => state.onConnect);
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 });
const [isGenerating, setIsGenerating] = useState(false);
const selectedModel = useMemo(
() => multiAngleModel ? toSelectedModel(multiAngleModel) : null,
[multiAngleModel]
);
const canGenerate = Boolean(selectedModel) && !isGenerating;
const updateSettings = useCallback((patch: Partial<MultiAngleSettings>) => {
setSettings((current) => ({
...current,
...patch,
preset: patch.preset ?? "custom",
}));
}, []);
const selectPreset = useCallback((preset: MultiAnglePresetId) => {
setSettings({ ...MULTI_ANGLE_PRESET_SETTINGS[preset] });
}, []);
const togglePrompt = useCallback((enabled: boolean) => {
setSettings((current) => ({
...current,
includePrompt: enabled,
}));
}, []);
const resetSettings = useCallback(() => {
setSettings((current) => ({
...DEFAULT_MULTI_ANGLE_SETTINGS,
includePrompt: current.includePrompt,
promptText: current.promptText,
}));
}, []);
const handleGenerate = useCallback(async () => {
if (!selectedModel || isGenerating) return;
setIsGenerating(true);
try {
const currentWidth = typeof node.width === "number"
? node.width
: typeof node.style?.width === "number"
? node.style.width
: 300;
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,
},
image: generatedImage.image,
previewImage: generatedImage.previewImage,
filename: `multi-angle-${Date.now()}.png`,
dimensions: null,
label: t("imageInput.multiAngle"),
operation: "multiAngle",
addNode,
onConnect,
selectSingleNode,
});
onClose();
} finally {
setIsGenerating(false);
}
}, [
addNode,
imageSource,
isGenerating,
node,
onClose,
onConnect,
providerSettings,
selectSingleNode,
selectedModel,
settings,
t,
]);
return (
<section
data-testid="node-inline-multi-angle-panel"
data-composer-popup-root="true"
className="nodrag nopan nowheel pointer-events-auto flex h-[340px] w-[600px] flex-col overflow-hidden rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-2)] text-[var(--text-primary)] shadow-[var(--shadow-panel)] backdrop-blur"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="min-h-0 flex-1 overflow-hidden">
<MultiAngleEditor
previewImage={imageSource}
settings={settings}
onChange={updateSettings}
onSelectPreset={selectPreset}
onTogglePrompt={togglePrompt}
onReset={resetSettings}
compact
hidePromptControl
actions={
<>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-2)] px-4 py-2 text-sm text-[var(--text-secondary)] transition-colors hover:border-[var(--border-strong)] hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
>
{t("common.cancel")}
</button>
<button
type="button"
aria-label={t("multiAngle.generate")}
disabled={!canGenerate}
onClick={() => void handleGenerate()}
className="rounded-lg bg-[var(--accent)] px-4 py-2 text-sm font-medium text-[var(--text-on-overlay)] transition-colors hover:bg-[var(--accent-hover)] disabled:cursor-not-allowed disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"
>
{t("multiAngle.generate")}
</button>
</>
}
/>
</div>
</section>
);
}