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
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>
|
|
);
|
|
}
|
|
|