diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 9a10139fc..55d79e71d 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -159,6 +159,7 @@ type RealtimeCanvasModeIconConfig = { const DEFAULT_REALTIME_CANVAS_MODE_LABEL = "实时画布模式"; const DEFAULT_ENHANCE_CANVAS_MODE_LABEL = "图像增强模式"; +const ANGLE_EDIT_CONFIG_KEY = "angle_edit_config"; const defaultRealtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig = { realtimeIconUrl: "", @@ -182,6 +183,9 @@ async function readCanvasFeatureAvailability(valueByKey: Map) { ]); return { + angleEditEnabled: normalizeAngleEditEnabled( + valueByKey.get(ANGLE_EDIT_CONFIG_KEY), + ), enhanceProEnabled: normalizeEnhanceProKreaConfig( valueByKey.get(ENHANCE_PRO_KREA_SETTING_KEY), ).enabled, @@ -441,6 +445,13 @@ function normalizeLayerSplitEnabled(value: unknown): boolean { return (value as { enabled?: unknown }).enabled === true; } +function normalizeAngleEditEnabled(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + return (value as { enabled?: unknown }).enabled === true; +} + function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConfig { const raw = value && typeof value === "object" && !Array.isArray(value) @@ -596,6 +607,7 @@ async function getScopedPublicSiteSettings(scope: PublicSiteSettingsScope) { REALTIME_CANVAS_MODE_ICON_CONFIG_KEY, CANVAS_TOOLBAR_CONFIG_SETTING_KEY, LAYER_SPLIT_CONFIG_KEY, + ANGLE_EDIT_CONFIG_KEY, CANVAS_AGENT_MODE_ENABLED_KEY, VIDEO_CANVAS_ENABLED_KEY, VIDEO_CANVAS_SIDEBAR_ENABLED_KEY, @@ -644,6 +656,7 @@ async function getScopedPublicSiteSettings(scope: PublicSiteSettingsScope) { layerSplitEnabled: normalizeLayerSplitEnabled( valueByKey.get(LAYER_SPLIT_CONFIG_KEY) ), + angleEditEnabled: canvasFeatureAvailability.angleEditEnabled, enhanceProEnabled: canvasFeatureAvailability.enhanceProEnabled, tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled, worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled, @@ -691,6 +704,7 @@ async function getScopedPublicSiteSettings(scope: PublicSiteSettingsScope) { REALTIME_CANVAS_MODE_ICON_CONFIG_KEY, CANVAS_TOOLBAR_CONFIG_SETTING_KEY, LAYER_SPLIT_CONFIG_KEY, + ANGLE_EDIT_CONFIG_KEY, CANVAS_AGENT_MODE_ENABLED_KEY, VIDEO_CANVAS_ENABLED_KEY, VIDEO_CANVAS_SIDEBAR_ENABLED_KEY, @@ -727,6 +741,7 @@ async function getScopedPublicSiteSettings(scope: PublicSiteSettingsScope) { layerSplitEnabled: normalizeLayerSplitEnabled( valueByKey.get(LAYER_SPLIT_CONFIG_KEY) ), + angleEditEnabled: canvasFeatureAvailability.angleEditEnabled, enhanceProEnabled: canvasFeatureAvailability.enhanceProEnabled, tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled, worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled, diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 8bb74b94a..ef00877e5 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -1784,6 +1784,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { canvasAgentModeEnabled?: boolean; videoCanvasSidebarEnabled?: boolean; layerSplitEnabled?: boolean; + angleEditEnabled?: boolean; realtimeCanvasEnabled?: boolean; realtimeCanvasServiceEnabled?: boolean; enhanceProEnabled?: boolean; @@ -1831,6 +1832,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { }, [canvasSiteSettingsKey]); const isLayerSplitEnabled = hasResolvedCanvasSiteSettings && siteSettingsData?.layerSplitEnabled === true; + const isAngleEditEnabled = + hasResolvedCanvasSiteSettings && siteSettingsData?.angleEditEnabled === true; const isTripo3dEnabled = hasResolvedCanvasSiteSettings && siteSettingsData?.tripo3dEnabled === true; const isWorldlabs3dEnabled = @@ -8976,6 +8979,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { workspaceLabel={workspaceLabel} workspaceVariant={isVideoMode ? "video" : "canvas"} layerSplitEnabled={isLayerSplitEnabled} + angleEditEnabled={isAngleEditEnabled} tripo3dEnabled={isTripo3dEnabled} worldlabs3dEnabled={isWorldlabs3dEnabled} /> diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index cafd9b197..18190cb8f 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -191,6 +191,7 @@ interface CanvasWorkspaceProps { workspaceVariant?: "canvas" | "video"; canvasToolbarConfig?: CanvasToolbarConfig; layerSplitEnabled?: boolean; + angleEditEnabled?: boolean; tripo3dEnabled?: boolean; worldlabs3dEnabled?: boolean; batchProcessingEnabled?: boolean; @@ -209,6 +210,7 @@ export function CanvasWorkspace({ workspaceVariant = "canvas", canvasToolbarConfig: rawCanvasToolbarConfig, layerSplitEnabled = false, + angleEditEnabled = false, tripo3dEnabled = false, worldlabs3dEnabled = false, batchProcessingEnabled = true, @@ -1517,6 +1519,7 @@ export function CanvasWorkspace({ { + if (!angleEditEnabled && anglePanelOpen) { + setAnglePanelOpen(false); + } + }, [angleEditEnabled, anglePanelOpen]); + async function handleConfirmThreedGeneration() { if (threedGenerationDialogMode === "model") { const targetModelVersion = selectedTripoModelEntry?.id || ""; @@ -4544,6 +4552,10 @@ export function ImageNode({ return; } if (action === "angle") { + if (!angleEditEnabled) { + toast.info("多视角功能未开启"); + return; + } setCropPanelOpen(false); setCropInteraction(null); setImageAdjustPanelOpen(false); diff --git a/lib/ai/providers/openai-image.ts b/lib/ai/providers/openai-image.ts index b583ab352..868e633f3 100644 --- a/lib/ai/providers/openai-image.ts +++ b/lib/ai/providers/openai-image.ts @@ -26,6 +26,8 @@ interface UploadedImage { const DEFAULT_IMAGE_SIZE = "2K"; const DEFAULT_TIMEOUT_MS = 300000; const OPENAI_IMAGE_MIN_TOTAL_PIXELS = 3_686_400; +const BLTCY_HOST_REGEX = /(^|\.)bltcy\.ai$/i; +const KONTEXT_MODEL_REGEX = /kontext/i; const SUPPORTED_ASPECT_RATIOS = [ "1:1", "1:4", @@ -55,6 +57,19 @@ function joinEndpoint(baseURL: string, path: string) { return `${normalizeBaseURL(baseURL)}${normalizedPath}`; } +function isBltcyBaseURL(baseURL: string) { + try { + const parsed = new URL(normalizeBaseURL(baseURL)); + return BLTCY_HOST_REGEX.test(parsed.hostname); + } catch { + return false; + } +} + +function shouldUseBltcyFluxCompatParams(baseURL: string, model: string) { + return isBltcyBaseURL(baseURL) && KONTEXT_MODEL_REGEX.test(String(model || "").trim()); +} + function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" { const normalized = String(imageSize || "").trim().toUpperCase(); if (normalized === "1K" || normalized === "2K" || normalized === "4K") return normalized; @@ -375,12 +390,31 @@ async function requestOpenAIImageGeneration(params: { model: string; prompt: string; size?: string; + aspectRatio?: string; + imageSizeLabel?: "1K" | "2K" | "4K"; imageUrls: string[]; quality?: string; background?: string; outputFormat?: string; }) { - const { baseURL, apiKey, model, prompt, size, imageUrls, quality, background, outputFormat } = params; + const { + baseURL, + apiKey, + model, + prompt, + size, + aspectRatio, + imageSizeLabel, + imageUrls, + quality, + background, + outputFormat, + } = params; + const useBltcyFluxCompatParams = shouldUseBltcyFluxCompatParams(baseURL, model); + const explicitAspectRatio = + typeof aspectRatio === "string" && aspectRatio.trim() && aspectRatio !== "auto" + ? aspectRatio.trim() + : undefined; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); @@ -392,6 +426,10 @@ async function requestOpenAIImageGeneration(params: { formData.append("model", model); formData.append("prompt", prompt); if (size) formData.append("size", size); + if (useBltcyFluxCompatParams) { + if (explicitAspectRatio) formData.append("aspect_ratio", explicitAspectRatio); + if (imageSizeLabel) formData.append("image_size", imageSizeLabel); + } formData.append("response_format", "url"); if (quality) formData.append("quality", quality); @@ -434,6 +472,10 @@ async function requestOpenAIImageGeneration(params: { model, prompt, ...(size ? { size } : {}), + ...(useBltcyFluxCompatParams && explicitAspectRatio + ? { aspect_ratio: explicitAspectRatio } + : {}), + ...(useBltcyFluxCompatParams && imageSizeLabel ? { image_size: imageSizeLabel } : {}), response_format: "url", ...(quality ? { quality } : {}), ...(background ? { background } : {}), @@ -504,6 +546,8 @@ export function createOpenAIImage({ apiKey, baseURL }: OpenAIImageConfig) { model: activeModelId, size, aspectRatio: aspectRatio || "auto", + imageSize, + useBltcyFluxCompatParams: shouldUseBltcyFluxCompatParams(baseURL, activeModelId), imageCount: imageUrls.length, baseURL: normalizeBaseURL(baseURL), }); @@ -514,6 +558,8 @@ export function createOpenAIImage({ apiKey, baseURL }: OpenAIImageConfig) { model: activeModelId, prompt, size, + aspectRatio: aspectRatio || undefined, + imageSizeLabel: imageSize, imageUrls, quality, background,