feat: add style pro realistic mode
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-17 17:43:09 +08:00
parent ce5daaea45
commit 0869eefee3
7 changed files with 463 additions and 96 deletions

View File

@ -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>

View File

@ -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() {
);
}
}

View File

@ -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(

View File

@ -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) {

View File

@ -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模型任务失败退回";

View File

@ -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

View File

@ -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) => ({