Improve nine grid storyboard generation
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-19 10:52:54 +08:00
parent f3869f5e63
commit 7a1a478eaf
2 changed files with 80 additions and 16 deletions

View File

@ -384,11 +384,15 @@ const OFFICIAL_TEXT_RESOURCE_WIDTH = 560;
const OFFICIAL_IMAGE_TOOLBAR_SCALE = 0.7;
const OFFICIAL_COMPOSER_SCALE = 0.546;
const OFFICIAL_ZOOM_DOCK_SCALE = 0.65;
const OFFICIAL_NINE_GRID_WIDTH = 930;
const OFFICIAL_NINE_GRID_HEIGHT = 520;
const PROMPT_COMPOSER_BASE_WIDTH = 895;
const PROMPT_COMPOSER_BASE_HEIGHT = 286;
const PRESET_COMPOSER_BASE_HEIGHT = 238;
const MULTI_VIEW_NINE_GRID_PROMPT =
const LEGACY_MULTI_VIEW_NINE_GRID_PROMPT =
"以选中图片为角色/主体参考,生成多视角 3x3 九宫格分镜图。保持主体一致,九个镜头包含远景、全身、侧身、背面、半身、近景、局部细节等视角,使用清晰黑色分割线分隔每一格,白色或干净背景。";
const MULTI_VIEW_NINE_GRID_PROMPT =
"以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。";
const NINE_GRID_CELL_VISUALS: GridCellVisual[] = [
{ fit: "contain", objectPosition: "50% 50%", scale: 0.62 },
{ fit: "contain", objectPosition: "58% 50%", scale: 0.74, translateX: -10 },
@ -1043,8 +1047,8 @@ function createNineGridCompositeNode({
inputType: "image",
x,
y,
width: 560,
height: 347,
width: OFFICIAL_NINE_GRID_WIDTH,
height: OFFICIAL_NINE_GRID_HEIGHT,
accent: "#8790ff",
draftValues: {
action: "IMAGE_GENERATE_NINE_GRID",
@ -1188,6 +1192,27 @@ function composeFunctionPrompt(
return `${base}\n\n用户补充:${extra}`;
}
function composeMultiViewNineGridPrompt(
definition: VideoCanvasFunctionDefinition,
option?: string,
) {
const configuredPrompt = definition.prompt.trim();
const selectedOption = String(option || "").trim();
const parts = [MULTI_VIEW_NINE_GRID_PROMPT];
const isLegacyPrompt =
configuredPrompt === LEGACY_MULTI_VIEW_NINE_GRID_PROMPT ||
(configuredPrompt.includes("以选中图片为角色/主体参考") && configuredPrompt.includes("白色或干净背景"));
if (configuredPrompt && configuredPrompt !== MULTI_VIEW_NINE_GRID_PROMPT && !isLegacyPrompt) {
parts.push(`后台补充:${configuredPrompt}`);
}
if (selectedOption) {
parts.push(`用户选择:${selectedOption}`);
}
return parts.join("\n\n");
}
function getFirstGeneratedImageUrl(
response: Awaited<ReturnType<typeof imageGenerationService.generateImage>>,
) {
@ -1496,10 +1521,29 @@ function normalizeTargetModuleNode(node: CloneNode, kind: AddModuleKind, fallbac
function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNode {
const action = getDraftString(node, "action");
const isNineGridResultNode =
action === "IMAGE_GENERATE_NINE_GRID" ||
Boolean(node.draftValues?.nineGridComposite) ||
(node.inputType === "image" && /多机位九宫格/.test(node.title || ""));
if (action === "TEXT_RESOURCE" && !getPresetMode(node)) {
return normalizeTextResourceNode(node, fallbackIndex);
}
if (isNineGridResultNode) {
return {
...node,
width: OFFICIAL_NINE_GRID_WIDTH,
height: OFFICIAL_NINE_GRID_HEIGHT,
draftValues: {
...(node.draftValues || {}),
action: "IMAGE_GENERATE_NINE_GRID",
gridPrompt: getDraftString(node, "gridPrompt") || MULTI_VIEW_NINE_GRID_PROMPT,
nineGridComposite: true,
outputSizeLabel: getDraftString(node, "outputSizeLabel") || "2752 × 1536",
},
};
}
const kind = getTargetModuleKind(node) || inferLegacyTargetModuleKind(node);
if (!kind) return node;
@ -2455,8 +2499,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const tileSize = tileCount === 25 ? 142 : tileCount === 16 ? 156 : 180;
const gap = 26;
const startY = Math.round(anchor.y + anchor.height / 2 - (rows * tileSize + (rows - 1) * gap) / 2);
const isSyntheticGrid = Boolean(anchor.draftValues?.nineGridComposite);
const anchorMediaUrl = getNodeMediaUrl(anchor);
const isSyntheticGrid = Boolean(anchor.draftValues?.nineGridComposite) && !anchorMediaUrl;
const detectedSplit =
!isSyntheticGrid && anchorMediaUrl
? await detectGridSplitFromImage(anchorMediaUrl, rows, columns)
@ -2535,7 +2579,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
rows,
sourceUrl,
x: rightX,
y: centerY(347),
y: centerY(OFFICIAL_NINE_GRID_HEIGHT),
});
const shouldGenerate = videoCanvasFunctionConfig.enabled && gridDefinition?.enabled;
const pendingGridNode: CloneNode = {
@ -2562,7 +2606,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
setActivePanel(null);
if (videoCanvasFunctionConfig.enabled && gridDefinition?.enabled) {
const prompt = composeFunctionPrompt(gridDefinition, option);
const prompt = composeMultiViewNineGridPrompt(gridDefinition, option);
const response = await imageGenerationService.generateImage({
aspectRatio: "16:9",
imageCount: 1,
@ -2810,6 +2854,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
definition,
payload.prompt.trim() || payload.slashScene || "生成角色三视图",
);
const finalPrompt =
functionKey === "multi-view-nine-grid"
? composeMultiViewNineGridPrompt(definition, payload.prompt.trim() || payload.slashScene || "多机位九宫格")
: prompt;
const count = Math.max(1, Math.min(4, Number.parseInt(payload.settings.count, 10) || 1));
let generatedUrl = "";
@ -2818,7 +2866,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
aspectRatio: payload.settings.ratio,
imageCount: count,
model: getFunctionModel(videoCanvasFunctionConfig, definition),
prompt,
prompt: finalPrompt,
referenceImage: sourceUrl,
});
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
@ -2830,14 +2878,14 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const generatedNode = generatedUrl
? createMediaResourceNode({
action: "IMAGE_RESOURCE",
height: functionKey === "multi-view-nine-grid" ? 347 : 280,
height: functionKey === "multi-view-nine-grid" ? OFFICIAL_NINE_GRID_HEIGHT : 280,
mediaType: "image",
mediaUrl: generatedUrl,
prompt,
prompt: finalPrompt,
title: payload.slashScene || definition.label || "图片生成",
width: functionKey === "multi-view-nine-grid" ? 560 : 420,
width: functionKey === "multi-view-nine-grid" ? OFFICIAL_NINE_GRID_WIDTH : 420,
x: rightX,
y: Math.round(anchor.y + anchor.height / 2 - (functionKey === "multi-view-nine-grid" ? 347 : 280) / 2),
y: Math.round(anchor.y + anchor.height / 2 - (functionKey === "multi-view-nine-grid" ? OFFICIAL_NINE_GRID_HEIGHT : 280) / 2),
})
: null;
const created = generatedNode || createTargetModuleNode("image", rightX, 0, nodes.length + 1);
@ -2851,7 +2899,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
...(created.draftValues || {}),
generated: Boolean(generatedUrl) || created.draftValues?.generated,
params: {
prompt,
prompt: finalPrompt,
references: payload.references,
settings: payload.settings,
slashScene: payload.slashScene,
@ -2915,7 +2963,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
imageUrls,
mode: "agent-plan",
nodeTitle: node.title,
prompt,
prompt: finalPrompt,
videoUrls,
});
if (!response.success) throw new Error(response.error || "故事脚本生成失败");
@ -7041,8 +7089,17 @@ function PresetMediaCanvasNode({
progress={getDraftNumber(node, "generationProgress", 2)}
status={generationStatus}
/>
) : isNineGridComposite && mediaUrl ? (
<img
alt={node.title}
className="pointer-events-none h-full w-full object-cover"
draggable={false}
onDragStart={preventNativeImageDrag}
src={mediaUrl}
style={{ transform: `rotate(${rotation}deg)` }}
/>
) : isNineGridComposite ? (
<NineGridCompositePreview node={node} sourceUrl={sourceUrl || mediaUrl} />
<NineGridCompositePreview node={node} sourceUrl={sourceUrl} />
) : cropTile && mediaUrl && gridCellVisual ? (
<GridCellImage alt={node.title} src={mediaUrl} visual={gridCellVisual} />
) : cropTile && mediaUrl ? (
@ -8275,9 +8332,16 @@ function ImageNodePreview({ node }: { node: CloneNode }) {
<div className="rounded-[8px] border border-white/8 bg-[#111] p-3">
{mediaType === "video" ? (
<video className="max-h-[72vh] w-full rounded-[6px] object-contain" controls playsInline src={url} />
) : isNineGridComposite && url ? (
<img
alt={node.title}
className="max-h-[72vh] w-full rounded-[6px] object-contain"
src={url}
style={{ transform: `rotate(${rotation}deg)` }}
/>
) : isNineGridComposite ? (
<div className="relative aspect-video max-h-[72vh] w-full overflow-hidden rounded-[6px] bg-white">
<NineGridCompositePreview node={node} sourceUrl={sourceUrl || url} />
<NineGridCompositePreview node={node} sourceUrl={sourceUrl} />
</div>
) : cropTile && url && gridCellVisual ? (
<div className="relative aspect-square max-h-[72vh] w-full overflow-hidden rounded-[6px] bg-white">

View File

@ -62,7 +62,7 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
enabled: true,
creditCost: 14,
prompt:
"以选中图片为角色/主体参考,生成多视角 3x3 九宫格分镜图。保持主体一致,九个镜头包含远景、全身、侧身、背面、半身、近景、局部细节等视角,使用清晰黑色分割线分隔每一格,白色或干净背景。",
"以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。",
description: "官方九宫格工具:先生成带黑色分割线的 3x3 分镜图,再由宫格切分拆成单张镜头。",
},
{