feat: add split-tile upscale toolbar
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-04-26 18:48:54 +08:00
parent d622b18ddf
commit 7f796a5bfd
3 changed files with 453 additions and 6 deletions

View File

@ -684,6 +684,7 @@ export async function POST(request: NextRequest) {
let chatId: string | null = null; // 聊天ID(可选)
let messageId: string | null = null; // 消息ID(可选)
let sourceImageUrl: string | null = null;
let requestedScaleFactor: number | undefined;
if (contentType.includes("application/json")) {
console.log("[Enhance API] 解析JSON请求体...");
@ -691,6 +692,11 @@ export async function POST(request: NextRequest) {
prompt = body.prompt || "图片变清晰处理";
chatId = body.chatId || null;
messageId = body.messageId || null;
const parsedScaleFactor = Number.parseInt(String(body.scaleFactor || ""), 10);
requestedScaleFactor =
Number.isFinite(parsedScaleFactor) && [1, 2, 4, 8].includes(parsedScaleFactor)
? parsedScaleFactor
: undefined;
// 优先使用 imageUrl,其次使用 image_base64
if (body.imageUrl) {
@ -767,7 +773,7 @@ export async function POST(request: NextRequest) {
const result = await processWithRealESRGAN(sourceImageUrl, {
backupApiUrl: config.backupApiUrl!,
backupApiKey: config.backupApiKey!,
backupScale: config.backupScale || "4",
backupScale: String(requestedScaleFactor || Number.parseInt(config.backupScale || "4", 10) || 4),
backupModel: config.backupModel || "RealESRGAN_x4plus",
});
@ -806,7 +812,7 @@ export async function POST(request: NextRequest) {
messageId: messageId || null,
thumbnailUrl,
originalUrl,
imageSize: `${config.backupScale}x放大`,
imageSize: `${requestedScaleFactor || Number.parseInt(config.backupScale || "4", 10) || 4}x放大`,
prompt: prompt || "Real-ESRGAN图片变清晰处理",
modelId: `realesrgan-${config.backupModel}`,
}).returning());
@ -851,7 +857,7 @@ export async function POST(request: NextRequest) {
sourceRowId: savedImage.id,
primaryParentAssetId: sourceAssets[0]?.id ?? null,
metadata: {
imageSize: `${config.backupScale}x放大`,
imageSize: `${requestedScaleFactor || Number.parseInt(config.backupScale || "4", 10) || 4}x放大`,
apiType: "realesrgan-url",
},
});
@ -926,7 +932,13 @@ export async function POST(request: NextRequest) {
// 使用异步模式(sync=0),根据官方示例
formData.append("sync", "0");
formData.append("type", config.type || "clean"); // 默认通用清晰化
const scaleFactorValue = typeof config.scaleFactor === "number" ? config.scaleFactor : (config.scaleFactor === "auto" ? undefined : Number.parseInt(String(config.scaleFactor)));
const scaleFactorValue =
requestedScaleFactor ||
(typeof config.scaleFactor === "number"
? config.scaleFactor
: config.scaleFactor === "auto"
? undefined
: Number.parseInt(String(config.scaleFactor)));
if (scaleFactorValue) {
formData.append("scale_factor", String(scaleFactorValue));
}

View File

@ -1226,6 +1226,12 @@ function getNodeGridSplitSourceUrl(node: CloneNode | null | undefined) {
return getNodeMediaUrl(node) || getNodeSourceMediaUrl(node);
}
function getGridSplitCellLabel(index: number, split: Pick<GridSplitState, "columns">) {
const row = Math.floor(index / split.columns) + 1;
const column = (index % split.columns) + 1;
return `${row}-${column}`;
}
function getOfficialImagePresetFunctionKey(value?: unknown) {
const text = String(value || "").trim();
if (!text) return null;
@ -3555,6 +3561,282 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
[]
);
const createGridSplitImageNodes = useCallback(
async (nodeId: string) => {
const anchor = nodes.find((node) => node.id === nodeId);
const split = getNodeGridSplitState(anchor);
const sourceUrl = getNodeGridSplitSourceUrl(anchor);
if (!anchor || !split || split.selectedIndices.length === 0 || !sourceUrl) {
toast.error("请先选择需要处理的宫格");
return;
}
const selectedIndices = split.selectedIndices.slice();
const selectedAssets = await Promise.all(
selectedIndices.map(async (index) => {
const rect = split.rects[index];
if (!rect) return null;
const url = await createGridSplitReferenceAsset({
index,
nodeId: anchor.id,
rect,
sourceUrl,
}).catch(() => "");
if (!url) return null;
return { index, rect, url };
})
);
const validAssets = selectedAssets.filter((item): item is NonNullable<typeof item> => Boolean(item));
if (validAssets.length === 0) {
toast.error("所选宫格暂时无法切出独立图片");
return;
}
const startX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
const columns = validAssets.length >= 4 ? 2 : 1;
const columnGap = 52;
const rowGap = 34;
const tileWidth = 428;
const tileHeight = 240;
const totalHeight =
Math.ceil(validAssets.length / columns) * tileHeight +
Math.max(0, Math.ceil(validAssets.length / columns) - 1) * rowGap;
const startY = Math.round(anchor.y + anchor.height / 2 - totalHeight / 2);
const createdNodes = validAssets.map((asset, order) => {
const column = order % columns;
const row = Math.floor(order / columns);
const title = `镜头 ${getGridSplitCellLabel(asset.index, split)}`;
return createMediaResourceNode({
action: "IMAGE_RESOURCE",
height: tileHeight,
mediaType: "image",
mediaUrl: asset.url,
title,
width: tileWidth,
x: startX + column * (tileWidth + columnGap),
y: startY + row * (tileHeight + rowGap),
});
}).map((node, order) => {
const asset = validAssets[order];
return {
...node,
subtitle: "宫格镜头",
draftValues: {
...(node.draftValues || {}),
cropTile: {
columns: split.columns,
detectedDivider: split.detectedDivider,
index: asset.index,
rect: asset.rect,
rows: split.rows,
sourceCompositeNodeId: anchor.id,
sourceMode: split.sourceMode,
},
mediaUrl: asset.url,
outputSizeLabel: undefined,
params: {
...(node.draftValues?.params as Record<string, unknown> | undefined),
cropRect: asset.rect,
sourceNodeId: anchor.id,
sourceUrl: asset.url,
},
sourceCompositeNodeId: anchor.id,
sourceNodeId: anchor.id,
sourceUrl: asset.url,
url: [asset.url],
},
} satisfies CloneNode;
});
setNodes((current) => [...current, ...createdNodes]);
setEdges((current) => [
...current,
...createdNodes.map((node) => ({
id: makeId("edge"),
source: anchor.id,
target: node.id,
})),
]);
setSelectedNodeId(createdNodes[0]?.id || anchor.id);
setCanvasCommand(null);
setNodeContextMenu(null);
setConnectionMenu(null);
setActivePanel(null);
toast.success(`已创建 ${createdNodes.length} 个宫格图片节点`);
},
[nodes]
);
const upscaleSelectedGridSplit = useCallback(
async (nodeId: string, scaleFactor: number) => {
const anchor = nodes.find((node) => node.id === nodeId);
const split = getNodeGridSplitState(anchor);
const sourceUrl = getNodeGridSplitSourceUrl(anchor);
if (!anchor || !split || split.selectedIndices.length === 0 || !sourceUrl) {
toast.error("请先选择需要高清放大的宫格");
return;
}
const definition = getConfiguredFunction(videoCanvasFunctionConfig, "hd-upscale");
const selectedIndices = split.selectedIndices.slice();
const selectedAssets = await Promise.all(
selectedIndices.map(async (index) => {
const rect = split.rects[index];
if (!rect) return null;
const url = await createGridSplitReferenceAsset({
index,
nodeId: anchor.id,
rect,
sourceUrl,
}).catch(() => "");
if (!url) return null;
return { index, rect, url };
})
);
const validAssets = selectedAssets.filter((item): item is NonNullable<typeof item> => Boolean(item));
if (validAssets.length === 0) {
toast.error("所选宫格暂时无法执行高清放大");
return;
}
const startX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
const columns = validAssets.length >= 4 ? 2 : 1;
const columnGap = 52;
const rowGap = 34;
const tileWidth = 560;
const tileHeight = 315;
const totalHeight =
Math.ceil(validAssets.length / columns) * tileHeight +
Math.max(0, Math.ceil(validAssets.length / columns) - 1) * rowGap;
const startY = Math.round(anchor.y + anchor.height / 2 - totalHeight / 2);
const pendingNodes = validAssets.map((asset, order) => {
const column = order % columns;
const row = Math.floor(order / columns);
const cellLabel = getGridSplitCellLabel(asset.index, split);
const pending = createMediaResourceNode({
action: "IMAGE_RESOURCE",
height: tileHeight,
mediaType: "image",
mediaUrl: asset.url,
prompt: definition.prompt,
title: `宫格高清 ${cellLabel}`,
width: tileWidth,
x: startX + column * (tileWidth + columnGap),
y: startY + row * (tileHeight + rowGap),
});
return {
...pending,
subtitle: "生成中",
draftValues: {
...(pending.draftValues || {}),
action: "IMAGE_RESOURCE",
functionKey: definition.key,
generated: false,
generationProgress: 2,
generationStatus: "pending",
mediaType: "image",
mediaUrl: asset.url,
nodeType: "image",
outputSizeLabel: `${scaleFactor}x 高清`,
params: {
functionKey: definition.key,
mode: "grid-split-upscale",
prompt: definition.prompt,
scaleFactor,
sourceNodeId: anchor.id,
sourceTileIndex: asset.index,
sourceUrl: asset.url,
},
pendingLabel: "生成中",
prompt: definition.prompt,
sourceCompositeNodeId: anchor.id,
sourceNodeId: anchor.id,
sourceUrl: asset.url,
toolbarOption: `x${scaleFactor}`,
url: [asset.url],
},
} satisfies CloneNode;
});
setNodes((current) => [...current, ...pendingNodes]);
setEdges((current) => [
...current,
...pendingNodes.map((node) => ({
id: makeId("edge"),
source: anchor.id,
target: node.id,
})),
]);
setSelectedNodeId(anchor.id);
setCanvasCommand(null);
setNodeContextMenu(null);
setConnectionMenu(null);
setActivePanel(null);
for (const [order, asset] of validAssets.entries()) {
const pendingNode = pendingNodes[order];
const response = await imageGenerationService.enhanceImage(asset.url, { scaleFactor });
const generatedUrl = response.success ? response.data?.url || "" : "";
if (generatedUrl) {
setNodes((current) =>
current.map((node) =>
node.id === pendingNode.id
? {
...node,
subtitle: "生成结果",
draftValues: {
...clearGridSplitDraftValues(node.draftValues),
functionKey: definition.key,
generated: true,
generationProgress: 100,
generationStatus: "complete",
mediaType: "image",
mediaUrl: generatedUrl,
nodeType: "image",
outputSizeLabel: `${scaleFactor}x 高清`,
params: {
...getNodeParams(node),
functionKey: definition.key,
prompt: definition.prompt,
scaleFactor,
sourceNodeId: anchor.id,
sourceTileIndex: asset.index,
sourceUrl: asset.url,
},
sourceCompositeNodeId: anchor.id,
sourceNodeId: anchor.id,
sourceUrl: asset.url,
url: [generatedUrl],
},
}
: node
)
);
} else {
setNodes((current) =>
current.map((node) =>
node.id === pendingNode.id
? {
...node,
draftValues: {
...clearGridSplitDraftValues(node.draftValues),
generationStatus: "failed",
generationError: response.error || "宫格高清放大失败",
},
}
: node
)
);
}
}
toast.success(`已提交 ${validAssets.length} 张宫格图的 ${scaleFactor}x 高清放大`);
},
[nodes, videoCanvasFunctionConfig]
);
const submitSelectedImagePrompt = useCallback(
async (nodeId: string, payload: ComposerSubmitPayload) => {
const anchor = nodes.find((node) => node.id === nodeId);
@ -4480,6 +4762,9 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
functionConfig={videoCanvasFunctionConfig}
modelOptions={canvasModelOptions}
nodes={nodes}
onClearGridSplitSelection={() => clearNodeGridSplitSelection(selectedPresetControlNode.id)}
onGridSplitCreateNodes={() => createGridSplitImageNodes(selectedPresetControlNode.id)}
onGridSplitUpscale={(scaleFactor) => upscaleSelectedGridSplit(selectedPresetControlNode.id, scaleFactor)}
onComposerSubmit={(payload) => submitSelectedImagePrompt(selectedPresetControlNode.id, payload)}
onImageToolAction={(action, option) => runSelectedImageToolAction(selectedPresetControlNode.id, action, option)}
onPresetSubmit={(prompt, settings) =>
@ -5225,12 +5510,124 @@ function MagnetGlyph() {
);
}
function GridSplitActionToolbar({
anchor,
creditCost,
onClearSelection,
onCreateNodes,
onUpscale,
selectedCount,
}: {
anchor?: PresetControlAnchor;
creditCost: number;
onClearSelection: () => void;
onCreateNodes: () => void;
onUpscale: (scaleFactor: number) => void;
selectedCount: number;
}) {
const [scaleFactor, setScaleFactor] = useState(2);
const [scaleMenuOpen, setScaleMenuOpen] = useState(false);
return (
<>
<div
className="pointer-events-auto absolute flex h-[58px] items-center gap-4 rounded-[14px] border border-white/8 bg-[#262626]/96 px-5 text-[16px] text-white/82 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]"
style={
anchor
? {
left: anchor.centerX,
top: anchor.topY - (24 * anchor.zoom + 10),
transform: `translateX(-50%) translateY(-100%) scale(${OFFICIAL_IMAGE_TOOLBAR_SCALE})`,
transformOrigin: "bottom center",
}
: {
left: "50%",
top: 168,
transform: `translateX(-50%) scale(${OFFICIAL_IMAGE_TOOLBAR_SCALE})`,
transformOrigin: "top center",
}
}
>
<button
className="flex h-10 w-10 items-center justify-center rounded-[10px] text-[22px] text-white/74 transition hover:bg-white/8 hover:text-white"
onClick={onClearSelection}
type="button"
>
↩
</button>
<div className="h-9 w-px bg-white/8" />
<span className="flex items-center gap-2 text-white/74">
<GridTilesGlyph />
<span className="text-[15px]">已选 {selectedCount} 个宫格</span>
</span>
<div className="h-9 w-px bg-white/8" />
<button
className="flex h-10 items-center gap-2 rounded-[10px] px-2 text-white/84 transition hover:bg-white/8"
onClick={onCreateNodes}
type="button"
>
<WorkflowGlyph />
<span className="whitespace-nowrap">创建生图节点</span>
</button>
<div className="h-9 w-px bg-white/8" />
<button
className="flex h-10 items-center gap-2 rounded-[10px] px-2 text-white/84 transition hover:bg-white/8"
onClick={() => onUpscale(scaleFactor)}
type="button"
>
<SceneToolIcon kind="hd" />
<span className="whitespace-nowrap">生成高清图片</span>
<span className="rounded-[7px] bg-white/10 px-2 py-0.5 text-[13px] text-white/72">
↑{Math.max(0, creditCost)}
</span>
</button>
<div className="relative">
<button
className={cn(
"flex h-10 min-w-[72px] items-center justify-between gap-2 rounded-[10px] px-3 text-white/84 transition hover:bg-white/8",
scaleMenuOpen && "bg-white/8"
)}
onClick={() => setScaleMenuOpen((current) => !current)}
type="button"
>
<span>{scaleFactor}倍</span>
<span className="text-[14px] text-white/58">⌄</span>
</button>
{scaleMenuOpen ? (
<div className="absolute right-0 top-[calc(100%+8px)] w-[108px] rounded-[12px] border border-white/8 bg-[#262626]/96 p-2 shadow-[0_18px_48px_rgba(0,0,0,0.42)] backdrop-blur-[12px]">
{[2, 4].map((option) => (
<button
className={cn(
"flex h-10 w-full items-center justify-center rounded-[8px] text-[14px] text-white/78 transition hover:bg-white/8 hover:text-white",
scaleFactor === option && "bg-white/10 text-white"
)}
key={option}
onClick={() => {
setScaleFactor(option);
setScaleMenuOpen(false);
}}
type="button"
>
{option}倍
</button>
))}
</div>
) : null}
</div>
</div>
</>
);
}
function PresetWorkflowControls({
anchor,
functionConfig,
mode,
modelOptions,
nodes,
onClearGridSplitSelection,
onGridSplitCreateNodes,
onGridSplitUpscale,
onComposerSubmit,
onImageToolAction,
onPresetSubmit,
@ -5241,6 +5638,9 @@ function PresetWorkflowControls({
mode: Exclude<ActivePresetControlMode, null>;
modelOptions: CanvasModelOption[];
nodes: CloneNode[];
onClearGridSplitSelection: () => void;
onGridSplitCreateNodes: () => void;
onGridSplitUpscale: (scaleFactor: number) => void;
onComposerSubmit: (payload: ComposerSubmitPayload) => void;
onImageToolAction: (action: ImageToolbarAction, option?: string) => void;
onPresetSubmit: (prompt: string, settings?: ComposerSettings) => void;
@ -5248,6 +5648,21 @@ function PresetWorkflowControls({
}) {
if (isOfficialImagePresetFunctionKey(mode)) {
const definition = getConfiguredFunction(functionConfig, mode);
const gridSplit = getNodeGridSplitState(selectedNode);
if (gridSplit && gridSplit.selectedIndices.length > 0) {
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<GridSplitActionToolbar
anchor={anchor}
creditCost={(getConfiguredFunction(functionConfig, "hd-upscale").creditCost || 0) * gridSplit.selectedIndices.length}
onClearSelection={onClearGridSplitSelection}
onCreateNodes={onGridSplitCreateNodes}
onUpscale={onGridSplitUpscale}
selectedCount={gridSplit.selectedIndices.length}
/>
</div>
);
}
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<CharacterPresetToolbar anchor={anchor} onToolAction={onImageToolAction} />
@ -8180,6 +8595,7 @@ function PresetMediaCanvasNode({
<GenerationPendingPreview
errorMessage={getDraftString(node, "generationError")}
onCancel={onCancelGeneration}
previewUrl={mediaUrl}
progress={getDraftNumber(node, "generationProgress", 2)}
status={generationStatus}
/>
@ -8242,11 +8658,13 @@ function PresetMediaCanvasNode({
function GenerationPendingPreview({
errorMessage,
onCancel,
previewUrl,
progress,
status,
}: {
errorMessage?: string;
onCancel: () => void;
previewUrl?: string;
progress: number;
status: string;
}) {
@ -8255,6 +8673,15 @@ function GenerationPendingPreview({
return (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden bg-[#303030]">
{previewUrl ? (
<img
alt=""
className="absolute inset-0 h-full w-full object-cover opacity-40 blur-[16px] saturate-[0.9]"
draggable={false}
onDragStart={preventNativeImageDrag}
src={previewUrl}
/>
) : null}
<style>{`
@keyframes libtv-loading-sweep {
0% { transform: translateX(0); opacity: 0; }

View File

@ -355,12 +355,20 @@ export const imageGenerationService = {
},
// 图片增强 - 使用图片URL
async enhanceImage(imageUrl: string): Promise<ApiResponse<{ url: string }>> {
async enhanceImage(
imageUrl: string,
options?: {
scaleFactor?: number;
}
): Promise<ApiResponse<{ url: string }>> {
try {
const response = await fetch('/api/enhance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl }), // 使用 imageUrl 参数
body: JSON.stringify({
imageUrl,
scaleFactor: options?.scaleFactor,
}),
});
const data = await response.json();
if (data.success && data.original_url) {