feat: add style pro realistic mode
This commit is contained in:
parent
ce5daaea45
commit
0869eefee3
@ -103,7 +103,7 @@ function createOption(categoryId: string): StyleProOption {
|
||||
function modelLabel(model: AiModelOption) {
|
||||
const name = model.displayName || model.modelId || model.id;
|
||||
const id = model.modelId && model.modelId !== name ? ` · ${model.modelId}` : "";
|
||||
return `${name}${id}`;
|
||||
return `${name}${id}${model.isHidden ? " · 前端隐藏" : ""}`;
|
||||
}
|
||||
|
||||
export function StyleProSettingsClient() {
|
||||
@ -123,16 +123,22 @@ export function StyleProSettingsClient() {
|
||||
setConfig(normalizeStyleProConfig(data.setting?.value));
|
||||
}, [data]);
|
||||
|
||||
const imageModels = useMemo(
|
||||
const allImageModels = useMemo(
|
||||
() =>
|
||||
(modelsData?.models || []).filter(
|
||||
(model) =>
|
||||
model.isEnabled !== false &&
|
||||
model.isHidden !== true &&
|
||||
model.supportsImageGen
|
||||
(model) => model.isEnabled !== false && model.supportsImageGen
|
||||
),
|
||||
[modelsData?.models]
|
||||
);
|
||||
const visibleImageModels = useMemo(
|
||||
() =>
|
||||
allImageModels.filter((model) => model.isHidden !== true),
|
||||
[allImageModels]
|
||||
);
|
||||
const hiddenImageModels = useMemo(
|
||||
() => allImageModels.filter((model) => model.isHidden === true),
|
||||
[allImageModels]
|
||||
);
|
||||
|
||||
const updateConfig = (updates: Partial<StyleProConfig>) => {
|
||||
setConfig((current) => ({ ...current, ...updates }));
|
||||
@ -298,7 +304,7 @@ export function StyleProSettingsClient() {
|
||||
<CardHeader>
|
||||
<CardTitle>基础设置</CardTitle>
|
||||
<CardDescription>
|
||||
控制功能开关、最终生图模型、默认提示词和生成规格。
|
||||
控制功能开关、风格化公开模型、实景化隐藏模型、默认提示词和生成规格。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||||
@ -316,7 +322,7 @@ export function StyleProSettingsClient() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>最终生图模型</Label>
|
||||
<Label>风格化默认生图模型</Label>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
@ -331,12 +337,12 @@ export function StyleProSettingsClient() {
|
||||
<SelectContent>
|
||||
<SelectItem value={AUTO_MODEL_VALUE}>自动选择可用图片模型</SelectItem>
|
||||
{config.modelId &&
|
||||
!imageModels.some((model) => model.modelId === config.modelId || model.id === config.modelId) ? (
|
||||
!visibleImageModels.some((model) => model.modelId === config.modelId || model.id === config.modelId) ? (
|
||||
<SelectItem value={config.modelId}>
|
||||
当前保存值:{config.modelId}
|
||||
</SelectItem>
|
||||
) : null}
|
||||
{imageModels.map((model) => {
|
||||
{visibleImageModels.map((model) => {
|
||||
const value = model.modelId || model.id;
|
||||
return (
|
||||
<SelectItem key={model.id} value={value}>
|
||||
@ -348,6 +354,113 @@ export function StyleProSettingsClient() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border p-4 md:col-span-2">
|
||||
<div>
|
||||
<Label>实景化隐藏模型链路</Label>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
前台只显示“实景化”模式,不展示模型名称;这里仅可选择已在 AI 模型中设置为“前端隐藏”的图片模型。第一阶段失败时第二阶段不会继续执行。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>第一阶段模型(用户不可见)</Label>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
realisticFirstModelId:
|
||||
value === AUTO_MODEL_VALUE ? "" : value,
|
||||
})
|
||||
}
|
||||
value={config.realisticFirstModelId || AUTO_MODEL_VALUE}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择第一阶段模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AUTO_MODEL_VALUE}>未配置</SelectItem>
|
||||
{config.realisticFirstModelId &&
|
||||
!hiddenImageModels.some(
|
||||
(model) =>
|
||||
model.modelId === config.realisticFirstModelId ||
|
||||
model.id === config.realisticFirstModelId
|
||||
) ? (
|
||||
<SelectItem value={config.realisticFirstModelId}>
|
||||
当前保存值(未在隐藏模型列表中):{config.realisticFirstModelId}
|
||||
</SelectItem>
|
||||
) : null}
|
||||
{hiddenImageModels.map((model) => {
|
||||
const value = model.modelId || model.id;
|
||||
return (
|
||||
<SelectItem key={model.id} value={value}>
|
||||
{modelLabel(model)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>第二阶段最终模型(用户不可见)</Label>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
realisticSecondModelId:
|
||||
value === AUTO_MODEL_VALUE ? "" : value,
|
||||
})
|
||||
}
|
||||
value={config.realisticSecondModelId || AUTO_MODEL_VALUE}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择第二阶段模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AUTO_MODEL_VALUE}>未配置</SelectItem>
|
||||
{config.realisticSecondModelId &&
|
||||
!hiddenImageModels.some(
|
||||
(model) =>
|
||||
model.modelId === config.realisticSecondModelId ||
|
||||
model.id === config.realisticSecondModelId
|
||||
) ? (
|
||||
<SelectItem value={config.realisticSecondModelId}>
|
||||
当前保存值(未在隐藏模型列表中):{config.realisticSecondModelId}
|
||||
</SelectItem>
|
||||
) : null}
|
||||
{hiddenImageModels.map((model) => {
|
||||
const value = model.modelId || model.id;
|
||||
return (
|
||||
<SelectItem key={model.id} value={value}>
|
||||
{modelLabel(model)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>第一阶段默认提示词</Label>
|
||||
<Textarea
|
||||
className="min-h-[120px]"
|
||||
onChange={(event) =>
|
||||
updateConfig({ realisticFirstPrompt: event.target.value })
|
||||
}
|
||||
value={config.realisticFirstPrompt}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>第二阶段最终提示词</Label>
|
||||
<Textarea
|
||||
className="min-h-[120px]"
|
||||
onChange={(event) =>
|
||||
updateConfig({ realisticSecondPrompt: event.target.value })
|
||||
}
|
||||
value={config.realisticSecondPrompt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>图片比例</Label>
|
||||
|
||||
@ -5,6 +5,10 @@ import {
|
||||
normalizeStyleProConfig,
|
||||
toPublicStyleProConfig,
|
||||
} from "@/lib/style-pro/config";
|
||||
import {
|
||||
findStyleProImageModelByRef,
|
||||
getStyleProImageModelEntries,
|
||||
} from "@/lib/style-pro/model-options";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@ -14,10 +18,28 @@ export async function GET() {
|
||||
() => null
|
||||
);
|
||||
const config = normalizeStyleProConfig(setting?.value);
|
||||
const hiddenEntries = (
|
||||
await getStyleProImageModelEntries({ includeHidden: true }).catch(
|
||||
() => []
|
||||
)
|
||||
).filter((entry) => entry.model.isHidden === true);
|
||||
const publicConfig = toPublicStyleProConfig(config);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
config: toPublicStyleProConfig(config),
|
||||
config: {
|
||||
...publicConfig,
|
||||
realisticModeAvailable: Boolean(
|
||||
findStyleProImageModelByRef(
|
||||
hiddenEntries,
|
||||
config.realisticFirstModelId
|
||||
) &&
|
||||
findStyleProImageModelByRef(
|
||||
hiddenEntries,
|
||||
config.realisticSecondModelId
|
||||
)
|
||||
),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Style Pro Config] Failed:", error);
|
||||
@ -27,4 +49,3 @@ export async function GET() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
STYLE_PRO_CONFIG_KEY,
|
||||
type StyleProCategory,
|
||||
type StyleProGenerationMode,
|
||||
type StyleProOption,
|
||||
normalizeStyleProConfig,
|
||||
} from "@/lib/style-pro/config";
|
||||
@ -22,6 +23,7 @@ export const dynamic = "force-dynamic";
|
||||
const generateRequestSchema = z.object({
|
||||
canvasId: z.string().trim().min(1).max(160).optional(),
|
||||
sourceImageUrl: z.string().trim().min(1).max(4000),
|
||||
generationMode: z.enum(["style", "realistic"]).optional(),
|
||||
modelId: z.string().trim().min(1).max(160).optional(),
|
||||
selections: z.record(z.array(z.string().trim().min(1).max(120)).max(20)),
|
||||
});
|
||||
@ -50,9 +52,11 @@ function buildSelectionPayload(selectedOptions: SelectedOption[]) {
|
||||
function composePrompt({
|
||||
defaultPrompt,
|
||||
selectedOptions,
|
||||
mode = "style-pro-final",
|
||||
}: {
|
||||
defaultPrompt: string;
|
||||
selectedOptions: SelectedOption[];
|
||||
mode?: string;
|
||||
}) {
|
||||
const selectionPayload = buildSelectionPayload(selectedOptions);
|
||||
const groupedPrompt = selectionPayload.reduce<Record<string, string[]>>(
|
||||
@ -70,7 +74,7 @@ function composePrompt({
|
||||
"请严格依据以下风格化 PRO 选择执行最终图片生成。用户不可见配置 JSON:",
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: "style-pro-final",
|
||||
mode,
|
||||
selections: selectionPayload,
|
||||
promptByKey: groupedPrompt,
|
||||
},
|
||||
@ -160,6 +164,8 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const body = generateRequestSchema.parse(await request.json());
|
||||
const generationMode: StyleProGenerationMode =
|
||||
body.generationMode === "realistic" ? "realistic" : "style";
|
||||
if (isDisallowedImageUrl(body.sourceImageUrl)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请使用已上传的图片链接生成" },
|
||||
@ -196,45 +202,94 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const imageModelEntries = await getStyleProImageModelEntries({
|
||||
includeHidden: false,
|
||||
});
|
||||
const configuredDefaultModel = findStyleProImageModelByRef(
|
||||
imageModelEntries,
|
||||
config.modelId
|
||||
const imageModelEntries = (
|
||||
await getStyleProImageModelEntries({
|
||||
includeHidden: generationMode === "realistic",
|
||||
})
|
||||
).filter((entry) =>
|
||||
generationMode === "realistic" ? entry.model.isHidden === true : true
|
||||
);
|
||||
let selectedModel =
|
||||
configuredDefaultModel ||
|
||||
imageModelEntries.find((entry) => entry.model.isDefault) ||
|
||||
imageModelEntries[0] ||
|
||||
null;
|
||||
|
||||
if (body.modelId) {
|
||||
selectedModel = findStyleProImageModelByRef(
|
||||
imageModelEntries,
|
||||
body.modelId
|
||||
const selectedModels =
|
||||
generationMode === "realistic"
|
||||
? (() => {
|
||||
const firstModel = findStyleProImageModelByRef(
|
||||
imageModelEntries,
|
||||
config.realisticFirstModelId
|
||||
);
|
||||
const secondModel = findStyleProImageModelByRef(
|
||||
imageModelEntries,
|
||||
config.realisticSecondModelId
|
||||
);
|
||||
return firstModel && secondModel
|
||||
? [firstModel, secondModel]
|
||||
: [];
|
||||
})()
|
||||
: (() => {
|
||||
const configuredDefaultModel = findStyleProImageModelByRef(
|
||||
imageModelEntries,
|
||||
config.modelId
|
||||
);
|
||||
let selectedModel =
|
||||
configuredDefaultModel ||
|
||||
imageModelEntries.find((entry) => entry.model.isDefault) ||
|
||||
imageModelEntries[0] ||
|
||||
null;
|
||||
|
||||
if (body.modelId) {
|
||||
selectedModel = findStyleProImageModelByRef(
|
||||
imageModelEntries,
|
||||
body.modelId
|
||||
);
|
||||
if (!selectedModel) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return selectedModel ? [selectedModel] : [];
|
||||
})();
|
||||
|
||||
if (generationMode === "realistic" && selectedModels.length < 2) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请先在后台配置实景化两段隐藏模型" },
|
||||
{ status: 400 }
|
||||
);
|
||||
if (!selectedModel) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "所选生图模型不可用" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!selectedModel) {
|
||||
if (generationMode === "style" && body.modelId && selectedModels.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "所选生图模型不可用" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (selectedModels.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "暂无可用的风格化 PRO 生图模型" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const selectedModelRef = selectedModel.model.id;
|
||||
const unitCost = getModelGenerationCreditCost(selectedModel.model, "image");
|
||||
const unitCost = selectedModels.reduce(
|
||||
(sum, entry) => sum + getModelGenerationCreditCost(entry.model, "image"),
|
||||
0
|
||||
);
|
||||
const billingModelId =
|
||||
generationMode === "realistic"
|
||||
? selectedModels.map((entry) => entry.model.id).join("+")
|
||||
: selectedModels[0].model.id;
|
||||
const billingModelName =
|
||||
generationMode === "realistic"
|
||||
? selectedModels.map((entry) => entry.model.displayName).join(" + ")
|
||||
: selectedModels[0].model.displayName;
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost,
|
||||
reason: "style_pro_generation",
|
||||
modelId: selectedModel.model.id,
|
||||
note: `风格化 PRO 生图消耗积分(${selectedModel.model.displayName})`,
|
||||
reason:
|
||||
generationMode === "realistic"
|
||||
? "style_pro_realistic_generation"
|
||||
: "style_pro_generation",
|
||||
modelId: billingModelId,
|
||||
note:
|
||||
generationMode === "realistic"
|
||||
? `风格化 PRO 实景化生图消耗积分(${billingModelName})`
|
||||
: `风格化 PRO 生图消耗积分(${billingModelName})`,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, {
|
||||
@ -246,22 +301,67 @@ export async function POST(request: Request) {
|
||||
defaultPrompt: config.defaultPrompt,
|
||||
selectedOptions: selectionResult.selectedOptions,
|
||||
});
|
||||
const result = await generateCanvasImage({
|
||||
prompt,
|
||||
negativePrompt: config.negativePrompt || undefined,
|
||||
model: selectedModelRef,
|
||||
aspectRatio: config.aspectRatio || "auto",
|
||||
imageSize: config.imageSize || "2K",
|
||||
imageCount: 1,
|
||||
referenceImage: body.sourceImageUrl,
|
||||
ownerKey: session.user.id,
|
||||
debugLabel: "style-pro",
|
||||
debugMeta: {
|
||||
canvasId: body.canvasId || null,
|
||||
requestedStyleProModelId: body.modelId || null,
|
||||
selectedOptionCount: selectionResult.selectedOptions.length,
|
||||
},
|
||||
});
|
||||
const result =
|
||||
generationMode === "realistic"
|
||||
? await (async () => {
|
||||
const firstModel = selectedModels[0];
|
||||
const secondModel = selectedModels[1];
|
||||
const firstResult = await generateCanvasImage({
|
||||
prompt: config.realisticFirstPrompt,
|
||||
negativePrompt: config.negativePrompt || undefined,
|
||||
model: firstModel.model.id,
|
||||
aspectRatio: config.aspectRatio || "auto",
|
||||
imageSize: config.imageSize || "2K",
|
||||
imageCount: 1,
|
||||
referenceImage: body.sourceImageUrl,
|
||||
ownerKey: session.user.id,
|
||||
debugLabel: "style-pro-realistic-stage1",
|
||||
debugMeta: {
|
||||
canvasId: body.canvasId || null,
|
||||
selectedOptionCount: selectionResult.selectedOptions.length,
|
||||
},
|
||||
});
|
||||
const intermediateImage = firstResult.images[0]?.originalUrl;
|
||||
if (!intermediateImage) {
|
||||
throw new Error("实景化第一阶段生成失败");
|
||||
}
|
||||
return generateCanvasImage({
|
||||
prompt: composePrompt({
|
||||
defaultPrompt: config.realisticSecondPrompt,
|
||||
selectedOptions: selectionResult.selectedOptions,
|
||||
mode: "style-pro-realistic-final",
|
||||
}),
|
||||
negativePrompt: config.negativePrompt || undefined,
|
||||
model: secondModel.model.id,
|
||||
aspectRatio: config.aspectRatio || "auto",
|
||||
imageSize: config.imageSize || "2K",
|
||||
imageCount: 1,
|
||||
referenceImage: intermediateImage,
|
||||
ownerKey: session.user.id,
|
||||
debugLabel: "style-pro-realistic-stage2",
|
||||
debugMeta: {
|
||||
canvasId: body.canvasId || null,
|
||||
selectedOptionCount: selectionResult.selectedOptions.length,
|
||||
firstStageModelRef: firstModel.model.id,
|
||||
},
|
||||
});
|
||||
})()
|
||||
: await generateCanvasImage({
|
||||
prompt,
|
||||
negativePrompt: config.negativePrompt || undefined,
|
||||
model: selectedModels[0].model.id,
|
||||
aspectRatio: config.aspectRatio || "auto",
|
||||
imageSize: config.imageSize || "2K",
|
||||
imageCount: 1,
|
||||
referenceImage: body.sourceImageUrl,
|
||||
ownerKey: session.user.id,
|
||||
debugLabel: "style-pro",
|
||||
debugMeta: {
|
||||
canvasId: body.canvasId || null,
|
||||
requestedStyleProModelId: body.modelId || null,
|
||||
selectedOptionCount: selectionResult.selectedOptions.length,
|
||||
},
|
||||
});
|
||||
const image = result.images[0];
|
||||
if (!image?.originalUrl) {
|
||||
return NextResponse.json(
|
||||
@ -278,7 +378,7 @@ export async function POST(request: Request) {
|
||||
} else if (!consumeResult?.ok) {
|
||||
console.warn("[Style Pro Generate] Failed to consume points:", {
|
||||
userId: session.user.id,
|
||||
modelId: selectedModel.model.id,
|
||||
modelId: billingModelId,
|
||||
unitCost: pointsCharge.unitCost,
|
||||
});
|
||||
}
|
||||
@ -294,6 +394,7 @@ export async function POST(request: Request) {
|
||||
thumbnailUrl: image.thumbnailUrl || image.originalUrl,
|
||||
model: result.model,
|
||||
modelRef: result.modelRef,
|
||||
generationMode,
|
||||
chargedPoints,
|
||||
selections: selectionResult.normalizedSelections,
|
||||
optionLabels: selectionResult.selectedOptions.map(
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
findStyleProImageModelByRef,
|
||||
@ -23,10 +24,14 @@ export async function GET() {
|
||||
);
|
||||
}
|
||||
|
||||
const [entries, setting] = await Promise.all([
|
||||
const [entries, allHiddenCapableEntries, setting] = await Promise.all([
|
||||
getStyleProImageModelEntries({ includeHidden: false }),
|
||||
getStyleProImageModelEntries({ includeHidden: true }),
|
||||
getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null),
|
||||
]);
|
||||
const hiddenEntries = allHiddenCapableEntries.filter(
|
||||
(entry) => entry.model.isHidden === true
|
||||
);
|
||||
const config = normalizeStyleProConfig(setting?.value);
|
||||
const configuredDefault = findStyleProImageModelByRef(
|
||||
entries,
|
||||
@ -37,10 +42,24 @@ export async function GET() {
|
||||
entries.find((entry) => entry.model.isDefault) ||
|
||||
entries[0] ||
|
||||
null;
|
||||
const realisticFirstModel = findStyleProImageModelByRef(
|
||||
hiddenEntries,
|
||||
config.realisticFirstModelId
|
||||
);
|
||||
const realisticSecondModel = findStyleProImageModelByRef(
|
||||
hiddenEntries,
|
||||
config.realisticSecondModelId
|
||||
);
|
||||
const realisticUnitCost =
|
||||
realisticFirstModel && realisticSecondModel
|
||||
? getModelGenerationCreditCost(realisticFirstModel.model, "image") +
|
||||
getModelGenerationCreditCost(realisticSecondModel.model, "image")
|
||||
: 0;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
defaultModelId: defaultEntry?.model.id || null,
|
||||
realisticUnitCost,
|
||||
models: entries.map(toPublicStyleProModelOption),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@ -28,6 +28,7 @@ const WORLD3D_LEDGER_NOTE_PATTERN = /^3D世界生成消耗积分((.+?))$/;
|
||||
function mapLedgerReasonLabel(reason: string) {
|
||||
if (reason === "image_generation") return "图片生成";
|
||||
if (reason === "style_pro_generation") return "风格化 PRO";
|
||||
if (reason === "style_pro_realistic_generation") return "风格化 PRO 实景化";
|
||||
if (reason === "model3d_generation") return "3D模型生成";
|
||||
if (reason === "model3d_task") return "3D模型任务";
|
||||
if (reason === "model3d_task_refund") return "3D模型任务失败退回";
|
||||
|
||||
@ -26,6 +26,7 @@ import { cn, fetcher } from "@/lib/utils";
|
||||
import type {
|
||||
PublicStyleProCategory,
|
||||
PublicStyleProConfig,
|
||||
StyleProGenerationMode,
|
||||
StyleProHistoryItem,
|
||||
StyleProPersistedState,
|
||||
StyleProSelectionMap,
|
||||
@ -58,6 +59,7 @@ type StyleProModelOption = {
|
||||
type StyleProModelsResponse = {
|
||||
success: boolean;
|
||||
defaultModelId?: string | null;
|
||||
realisticUnitCost?: number | null;
|
||||
models?: StyleProModelOption[];
|
||||
error?: string;
|
||||
};
|
||||
@ -93,6 +95,8 @@ type StyleProGenerateResponse = {
|
||||
thumbnailUrl?: string;
|
||||
model?: string;
|
||||
modelRef?: string;
|
||||
generationMode?: StyleProGenerationMode;
|
||||
chargedPoints?: number;
|
||||
selections: StyleProSelectionMap;
|
||||
optionLabels: string[];
|
||||
};
|
||||
@ -741,6 +745,8 @@ export function StyleProWorkspace({
|
||||
.history
|
||||
);
|
||||
const [selections, setSelections] = useState<StyleProSelectionMap>({});
|
||||
const [generationMode, setGenerationMode] =
|
||||
useState<StyleProGenerationMode>("style");
|
||||
const [selectedModelId, setSelectedModelId] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
@ -818,6 +824,17 @@ export function StyleProWorkspace({
|
||||
: modelsLoading
|
||||
? "加载价格"
|
||||
: "暂无模型";
|
||||
const realisticUnitCost = Math.max(0, Number(modelsData?.realisticUnitCost || 0));
|
||||
const currentCostLabel =
|
||||
generationMode === "realistic"
|
||||
? realisticUnitCost > 0
|
||||
? `${realisticUnitCost} 积分/张`
|
||||
: "后台模型未计费"
|
||||
: selectedModelCostLabel;
|
||||
const currentUnitCost =
|
||||
generationMode === "realistic"
|
||||
? realisticUnitCost
|
||||
: selectedModel?.unitCost || 0;
|
||||
|
||||
const missingRequiredCategories = useMemo(
|
||||
() =>
|
||||
@ -922,7 +939,11 @@ export function StyleProWorkspace({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
canvasId,
|
||||
modelId: selectedModel?.id || selectedModelId || undefined,
|
||||
generationMode,
|
||||
modelId:
|
||||
generationMode === "style"
|
||||
? selectedModel?.id || selectedModelId || undefined
|
||||
: undefined,
|
||||
sourceImageUrl,
|
||||
selections,
|
||||
}),
|
||||
@ -941,6 +962,7 @@ export function StyleProWorkspace({
|
||||
selections: payload.data.selections || selections,
|
||||
optionLabels: payload.data.optionLabels || selectedOptionLabels,
|
||||
createdAt: new Date().toISOString(),
|
||||
generationMode: payload.data.generationMode || generationMode,
|
||||
model: payload.data.model,
|
||||
modelRef: payload.data.modelRef,
|
||||
width: dimensions.width,
|
||||
@ -963,6 +985,7 @@ export function StyleProWorkspace({
|
||||
canvasId,
|
||||
config?.enabled,
|
||||
history,
|
||||
generationMode,
|
||||
missingRequiredCategories,
|
||||
persistState,
|
||||
selectedOptionLabels,
|
||||
@ -975,12 +998,17 @@ export function StyleProWorkspace({
|
||||
const restoreHistoryItem = useCallback((item: StyleProHistoryItem) => {
|
||||
setSourceImageUrl(item.sourceImageUrl);
|
||||
setSelections(item.selections || {});
|
||||
setGenerationMode(item.generationMode || "style");
|
||||
setCurrentResult(item);
|
||||
}, []);
|
||||
|
||||
const disabledReason = !sourceImageUrl
|
||||
? "请先上传图片"
|
||||
: modelsData?.success === true && modelOptions.length === 0
|
||||
: generationMode === "realistic" && config?.realisticModeAvailable === false
|
||||
? "请先配置实景化模型"
|
||||
: generationMode === "style" &&
|
||||
modelsData?.success === true &&
|
||||
modelOptions.length === 0
|
||||
? "暂无可用生图模型"
|
||||
: missingRequiredCategories.length > 0
|
||||
? `还差:${missingRequiredCategories[0].title}`
|
||||
@ -1177,49 +1205,75 @@ export function StyleProWorkspace({
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex h-8 items-center gap-2 rounded-[14px] border border-black/10 bg-white px-2 text-[12px] text-black/65 shadow-sm">
|
||||
<span className="text-black/45">模型</span>
|
||||
<span className="text-black/45">模式</span>
|
||||
<select
|
||||
aria-label="选择生图模型"
|
||||
className="h-6 max-w-[210px] bg-transparent font-semibold text-black/75 outline-none"
|
||||
disabled={
|
||||
modelsLoading ||
|
||||
modelOptions.length === 0 ||
|
||||
generating
|
||||
}
|
||||
aria-label="选择生成模式"
|
||||
className="h-6 max-w-[110px] bg-transparent font-semibold text-black/75 outline-none"
|
||||
disabled={generating}
|
||||
onChange={(event) =>
|
||||
setSelectedModelId(event.target.value)
|
||||
setGenerationMode(
|
||||
event.target.value === "realistic"
|
||||
? "realistic"
|
||||
: "style"
|
||||
)
|
||||
}
|
||||
value={selectedModel?.id || selectedModelId}
|
||||
value={generationMode}
|
||||
>
|
||||
{modelOptions.length > 0 ? (
|
||||
modelOptions.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.displayName} ·{" "}
|
||||
{model.unitCost > 0
|
||||
? `${model.unitCost}积分/张`
|
||||
: "未设置积分"}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="">
|
||||
{modelsLoading ? "加载模型" : "暂无模型"}
|
||||
</option>
|
||||
)}
|
||||
<option value="style">风格化</option>
|
||||
<option value="realistic">实景化</option>
|
||||
</select>
|
||||
<span className="rounded-full bg-black/[0.04] px-2 py-0.5 font-semibold text-black/55">
|
||||
{selectedModelCostLabel}
|
||||
</span>
|
||||
{selectedModel?.creditDiscountLabel ? (
|
||||
<span className="rounded-full bg-emerald-50 px-2 py-0.5 font-semibold text-emerald-600">
|
||||
{selectedModel.creditDiscountLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{generationMode === "style" ? (
|
||||
<div className="flex h-8 items-center gap-2 rounded-[14px] border border-black/10 bg-white px-2 text-[12px] text-black/65 shadow-sm">
|
||||
<span className="text-black/45">模型</span>
|
||||
<select
|
||||
aria-label="选择生图模型"
|
||||
className="h-6 max-w-[210px] bg-transparent font-semibold text-black/75 outline-none"
|
||||
disabled={
|
||||
modelsLoading ||
|
||||
modelOptions.length === 0 ||
|
||||
generating
|
||||
}
|
||||
onChange={(event) =>
|
||||
setSelectedModelId(event.target.value)
|
||||
}
|
||||
value={selectedModel?.id || selectedModelId}
|
||||
>
|
||||
{modelOptions.length > 0 ? (
|
||||
modelOptions.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.displayName} ·{" "}
|
||||
{model.unitCost > 0
|
||||
? `${model.unitCost}积分/张`
|
||||
: "未设置积分"}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="">
|
||||
{modelsLoading ? "加载模型" : "暂无模型"}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
<span className="rounded-full bg-black/[0.04] px-2 py-0.5 font-semibold text-black/55">
|
||||
{currentCostLabel}
|
||||
</span>
|
||||
{selectedModel?.creditDiscountLabel ? (
|
||||
<span className="rounded-full bg-emerald-50 px-2 py-0.5 font-semibold text-emerald-600">
|
||||
{selectedModel.creditDiscountLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{generationMode === "realistic" ? (
|
||||
<div className="flex h-8 items-center rounded-[14px] border border-black/10 bg-white px-3 text-[12px] font-semibold text-black/55 shadow-sm">
|
||||
{currentCostLabel}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
className="h-8 rounded-[14px] bg-[#1c1c1e] px-4 text-[12px] text-white hover:bg-black"
|
||||
disabled={generating || Boolean(disabledReason)}
|
||||
onClick={handleGenerate}
|
||||
title={disabledReason || selectedModelCostLabel}
|
||||
title={disabledReason || currentCostLabel}
|
||||
>
|
||||
{generating ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
@ -1228,8 +1282,8 @@ export function StyleProWorkspace({
|
||||
)}
|
||||
{generating
|
||||
? "正在创建图片"
|
||||
: selectedModel?.unitCost
|
||||
? `执行 · ${selectedModel.unitCost}积分`
|
||||
: currentUnitCost
|
||||
? `执行 · ${currentUnitCost}积分`
|
||||
: "执行"}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@ -23,10 +23,15 @@ export type StyleProCategory = {
|
||||
};
|
||||
|
||||
export type StyleProPromptDisplayMode = "stacked" | "carousel";
|
||||
export type StyleProGenerationMode = "style" | "realistic";
|
||||
|
||||
export type StyleProConfig = {
|
||||
enabled: boolean;
|
||||
modelId: string;
|
||||
realisticFirstModelId: string;
|
||||
realisticSecondModelId: string;
|
||||
realisticFirstPrompt: string;
|
||||
realisticSecondPrompt: string;
|
||||
defaultPrompt: string;
|
||||
negativePrompt: string;
|
||||
aspectRatio: string;
|
||||
@ -43,8 +48,16 @@ export type PublicStyleProCategory = Omit<StyleProCategory, "options"> & {
|
||||
|
||||
export type PublicStyleProConfig = Omit<
|
||||
StyleProConfig,
|
||||
"modelId" | "defaultPrompt" | "negativePrompt" | "categories"
|
||||
| "modelId"
|
||||
| "realisticFirstModelId"
|
||||
| "realisticSecondModelId"
|
||||
| "realisticFirstPrompt"
|
||||
| "realisticSecondPrompt"
|
||||
| "defaultPrompt"
|
||||
| "negativePrompt"
|
||||
| "categories"
|
||||
> & {
|
||||
realisticModeAvailable: boolean;
|
||||
categories: PublicStyleProCategory[];
|
||||
};
|
||||
|
||||
@ -57,6 +70,7 @@ export type StyleProHistoryItem = {
|
||||
selections: StyleProSelectionMap;
|
||||
optionLabels: string[];
|
||||
createdAt: string;
|
||||
generationMode?: StyleProGenerationMode;
|
||||
model?: string;
|
||||
modelRef?: string;
|
||||
width?: number;
|
||||
@ -75,6 +89,20 @@ const DEFAULT_PROMPT = [
|
||||
"不要新增无关主体,不要改变产品类别,不要破坏原始构图逻辑。",
|
||||
].join("\n");
|
||||
|
||||
const DEFAULT_REALISTIC_FIRST_PROMPT = [
|
||||
"你是专业商业摄影实景化重塑摄影师。",
|
||||
"请先基于用户上传图片生成一张真实摄影感的中间图。",
|
||||
"保持主体结构、比例、数量、位置和核心识别特征一致。",
|
||||
"只优化真实光影、材质、环境细节和空间可信度,不新增无关主体。",
|
||||
].join("\n");
|
||||
|
||||
const DEFAULT_REALISTIC_SECOND_PROMPT = [
|
||||
"你是专业商业视觉最终成片摄影师。",
|
||||
"请基于上一阶段中间图进行最终实景化成片。",
|
||||
"严格保持主体结构和画面逻辑,融合用户选择的隐藏 JSON 提示词。",
|
||||
"输出真实、自然、可商用的最终图片。",
|
||||
].join("\n");
|
||||
|
||||
function presetOption(
|
||||
id: string,
|
||||
label: string,
|
||||
@ -404,6 +432,10 @@ const INDEX_25_STYLE_PRO_CATEGORIES: StyleProCategory[] = [
|
||||
export const defaultStyleProConfig: StyleProConfig = {
|
||||
enabled: true,
|
||||
modelId: "",
|
||||
realisticFirstModelId: "",
|
||||
realisticSecondModelId: "",
|
||||
realisticFirstPrompt: DEFAULT_REALISTIC_FIRST_PROMPT,
|
||||
realisticSecondPrompt: DEFAULT_REALISTIC_SECOND_PROMPT,
|
||||
defaultPrompt: DEFAULT_PROMPT,
|
||||
negativePrompt: "低清晰度,畸变,文字乱码,主体变形,产品结构错误,多余物体,水印",
|
||||
aspectRatio: "auto",
|
||||
@ -511,6 +543,26 @@ export function normalizeStyleProConfig(value: unknown): StyleProConfig {
|
||||
return {
|
||||
enabled: typeof raw.enabled === "boolean" ? raw.enabled : fallback.enabled,
|
||||
modelId: cleanString(raw.modelId, fallback.modelId, 160),
|
||||
realisticFirstModelId: cleanString(
|
||||
raw.realisticFirstModelId,
|
||||
fallback.realisticFirstModelId,
|
||||
160
|
||||
),
|
||||
realisticSecondModelId: cleanString(
|
||||
raw.realisticSecondModelId,
|
||||
fallback.realisticSecondModelId,
|
||||
160
|
||||
),
|
||||
realisticFirstPrompt: cleanString(
|
||||
raw.realisticFirstPrompt,
|
||||
fallback.realisticFirstPrompt,
|
||||
8000
|
||||
),
|
||||
realisticSecondPrompt: cleanString(
|
||||
raw.realisticSecondPrompt,
|
||||
fallback.realisticSecondPrompt,
|
||||
8000
|
||||
),
|
||||
defaultPrompt: cleanString(raw.defaultPrompt, fallback.defaultPrompt, 8000),
|
||||
negativePrompt: cleanString(raw.negativePrompt, fallback.negativePrompt, 4000),
|
||||
aspectRatio: cleanString(raw.aspectRatio, fallback.aspectRatio, 32),
|
||||
@ -555,12 +607,15 @@ export function normalizeStyleProPersistedState(
|
||||
createdAt:
|
||||
cleanString(itemRaw.createdAt, "", 80) || new Date(0).toISOString(),
|
||||
};
|
||||
const generationMode =
|
||||
itemRaw.generationMode === "realistic" ? "realistic" : "style";
|
||||
const model = cleanString(itemRaw.model, "", 120);
|
||||
const modelRef = cleanString(itemRaw.modelRef, "", 160);
|
||||
const width = cleanNumber(itemRaw.width, 0, 0, 20000);
|
||||
const height = cleanNumber(itemRaw.height, 0, 0, 20000);
|
||||
if (model) normalizedItem.model = model;
|
||||
if (modelRef) normalizedItem.modelRef = modelRef;
|
||||
normalizedItem.generationMode = generationMode;
|
||||
if (width) normalizedItem.width = width;
|
||||
if (height) normalizedItem.height = height;
|
||||
return normalizedItem;
|
||||
@ -582,6 +637,9 @@ export function toPublicStyleProConfig(
|
||||
aspectRatio: config.aspectRatio,
|
||||
imageSize: config.imageSize,
|
||||
promptDisplayMode: config.promptDisplayMode,
|
||||
realisticModeAvailable: Boolean(
|
||||
config.realisticFirstModelId && config.realisticSecondModelId
|
||||
),
|
||||
categories: config.categories
|
||||
.filter((category) => category.enabled)
|
||||
.map((category) => ({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user