merge: official v1.5.4 release

This commit is contained in:
Codex
2026-09-19 15:36:02 +08:00
189 changed files with 13222 additions and 8821 deletions

View File

@@ -5,82 +5,17 @@ import type { CanvasBatchOperation, CanvasBatchReferenceColumn, CanvasBatchRow,
export const TRY_ON_BATCH_PROMPT = "参考图1是人物原图,参考图2是目标服装。保持人物身份、五官、姿态和背景不变,将人物服装替换为参考图2中的款式。准确还原服装版型、颜色、材质、纹理和装饰细节,穿着关系自然,光影与原图一致。";
export const CREATIVE_BATCH_PROMPT = "基于参考图创作一张新的商业图片,保留主体身份和关键产品细节,画面构图完整,光影自然。";
export const BATCH_REFERENCE_HANDLE_PREFIX = "batch-reference:";
export const BATCH_REFERENCE_HANDLE_TOP = 112;
export const BATCH_REFERENCE_HANDLE_GAP = 38;
export const MAX_BATCH_REFERENCE_COLUMNS = 10;
export const MAX_BATCH_TEXT_COLUMNS = 4;
export const BATCH_TEXT_HANDLE_PREFIX = "batch-text:";
/** 两行工具栏(操作栏 44 + 全局提示词 36 + 分隔 1)之后,对准表头中线。 */
export const BATCH_REFERENCE_HANDLE_TOP = 102;
export const BATCH_REFERENCE_HANDLE_GAP = 40;
export const MIN_BATCH_REFERENCE_COLUMNS = 1;
export const MAX_BATCH_REFERENCE_COLUMNS = 6;
const LEGACY_BATCH_TABLE_WIDTH = 900;
export function batchGridTemplateColumns(referenceCount: number, textCount: number) {
return [
"36px",
"68px",
`repeat(${referenceCount}, 72px)`,
textCount > 0 ? `repeat(${textCount}, minmax(168px, 0.75fr))` : undefined,
"minmax(280px, 1fr)",
"148px",
"36px",
].filter(Boolean).join(" ");
}
export function reorderBatchReferenceColumns(table: CanvasBatchTableData, fromColumnId: string, toColumnId: string): CanvasBatchTableData {
const columns = batchReferenceColumns(table);
const fromIndex = columns.findIndex((column) => column.id === fromColumnId);
const toIndex = columns.findIndex((column) => column.id === toColumnId);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return table;
const nextColumns = [...columns];
const [moved] = nextColumns.splice(fromIndex, 1);
nextColumns.splice(toIndex, 0, moved);
const nextRows = table.rows.map((row) => ({
...row,
inputNodeIds: nextColumns.map((column) => {
const oldIndex = columns.findIndex((item) => item.id === column.id);
return row.inputNodeIds[oldIndex] || "";
}),
}));
return {
...table,
referenceColumns: nextColumns.map((column, index) => ({ ...column, label: `参考图 ${index + 1}` })),
rows: nextRows,
};
}
export function moveBatchReferenceCell(
table: CanvasBatchTableData,
sourceRowId: string,
sourceColumnIndex: number,
targetRowId: string,
targetColumnIndex: number,
): CanvasBatchTableData {
if (sourceRowId === targetRowId && sourceColumnIndex === targetColumnIndex) return table;
if (sourceColumnIndex < 0 || targetColumnIndex < 0) return table;
const referenceCount = Math.max(batchReferenceColumns(table).length, sourceColumnIndex + 1, targetColumnIndex + 1);
const sourceRow = table.rows.find((row) => row.id === sourceRowId);
const targetRow = table.rows.find((row) => row.id === targetRowId);
if (!sourceRow || !targetRow) return table;
const nextRows = table.rows.map((row) => ({
...row,
inputNodeIds: Array.from({ length: referenceCount }, (_, index) => row.inputNodeIds[index] || ""),
}));
const nextSourceRow = nextRows.find((row) => row.id === sourceRowId)!;
const nextTargetRow = nextRows.find((row) => row.id === targetRowId)!;
const sourceNodeId = nextSourceRow.inputNodeIds[sourceColumnIndex];
if (!sourceNodeId) return table;
const targetNodeId = nextTargetRow.inputNodeIds[targetColumnIndex];
nextTargetRow.inputNodeIds[targetColumnIndex] = sourceNodeId;
nextSourceRow.inputNodeIds[sourceColumnIndex] = targetNodeId || "";
return { ...table, rows: nextRows };
}
// Reserve one slot for adding a reference and a separate gap before text inputs.
export function batchTextHandleTop(referenceCount: number) {
return BATCH_REFERENCE_HANDLE_TOP + (referenceCount + 1) * BATCH_REFERENCE_HANDLE_GAP + 24;
/** 旧默认 900 宽的批量创作表升级到当前默认尺寸,已经手动改过宽度的节点保持原样。 */
export function promoteLegacyBatchTableSize(node: CanvasNodeData): CanvasNodeData {
if (node.type !== "batch-table" || node.width !== LEGACY_BATCH_TABLE_WIDTH) return node;
return { ...node, width: 1280, height: Math.max(node.height, 560) };
}
export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] {
@@ -94,10 +29,6 @@ export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] {
export function batchReferenceColumns(table?: CanvasBatchTableData) {
const columns = table?.referenceColumns;
if (!columns?.length) return defaultBatchReferenceColumns();
// Older tables were created before the second default slot existed. They
// never had a remove-column action, so extending this exact legacy shape is
// a safe migration and makes reference slot 2 immediately selectable.
if (columns.length === 1 && columns[0].id === "reference-1") return defaultBatchReferenceColumns();
return columns;
}
@@ -105,47 +36,16 @@ export function batchReferenceHandleId(columnId: string) {
return `${BATCH_REFERENCE_HANDLE_PREFIX}${columnId}`;
}
export function batchReferenceMentionToken(index: number) {
return `@参考图${index + 1}`;
}
export function batchReferenceColumnId(handleId?: string) {
return handleId?.startsWith(BATCH_REFERENCE_HANDLE_PREFIX) ? handleId.slice(BATCH_REFERENCE_HANDLE_PREFIX.length) : undefined;
}
export function batchTextColumns(table?: CanvasBatchTableData) {
return table?.textColumns || [];
}
export function batchTextHandleId(columnId: string) {
return `${BATCH_TEXT_HANDLE_PREFIX}${columnId}`;
}
export function batchTextColumnId(handleId?: string) {
return handleId?.startsWith(BATCH_TEXT_HANDLE_PREFIX) ? handleId.slice(BATCH_TEXT_HANDLE_PREFIX.length) : undefined;
}
export function batchTextHandleY(node: CanvasNodeData, handleId?: string) {
if (node.type !== "batch-table") return undefined;
const columnId = batchTextColumnId(handleId);
const columns = batchTextColumns(node.metadata?.batchTable);
const index = columnId ? columns.findIndex((column) => column.id === columnId) : -1;
if (index < 0) return undefined;
return node.position.y + batchTextHandleTop(batchReferenceColumns(node.metadata?.batchTable).length) + index * BATCH_REFERENCE_HANDLE_GAP;
}
export function batchTextHandleAtY(node: CanvasNodeData, worldY: number, hitRadius = 18) {
if (node.type !== "batch-table") return undefined;
const columns = batchTextColumns(node.metadata?.batchTable);
const referenceCount = batchReferenceColumns(node.metadata?.batchTable).length;
let nearestIndex = -1;
let nearestDistance = Number.POSITIVE_INFINITY;
columns.forEach((_, index) => {
const distance = Math.abs(worldY - (node.position.y + batchTextHandleTop(referenceCount) + index * BATCH_REFERENCE_HANDLE_GAP));
if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = index; }
});
return nearestIndex >= 0 && nearestDistance <= hitRadius ? batchTextHandleId(columns[nearestIndex].id) : undefined;
}
export function batchReferenceHandleY(node: CanvasNodeData, handleId?: string) {
if (node.type !== "batch-table") return undefined;
const textY = batchTextHandleY(node, handleId);
if (textY !== undefined) return textY;
const columnId = batchReferenceColumnId(handleId);
const columns = batchReferenceColumns(node.metadata?.batchTable);
const index = columnId ? columns.findIndex((column) => column.id === columnId) : 0;
@@ -155,17 +55,71 @@ export function batchReferenceHandleY(node: CanvasNodeData, handleId?: string) {
export function batchReferenceHandleAtY(node: CanvasNodeData, worldY: number, hitRadius = 18) {
if (node.type !== "batch-table") return undefined;
const referenceColumns = batchReferenceColumns(node.metadata?.batchTable);
const textColumns = batchTextColumns(node.metadata?.batchTable);
const handles = [
...referenceColumns.map((column, index) => ({ id: batchReferenceHandleId(column.id), y: BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP })),
...textColumns.map((column, index) => ({ id: batchTextHandleId(column.id), y: batchTextHandleTop(referenceColumns.length) + index * BATCH_REFERENCE_HANDLE_GAP })),
];
const nearest = handles.reduce<{ id: string; distance: number } | null>((current, handle) => {
const distance = Math.abs(worldY - (node.position.y + handle.y));
return !current || distance < current.distance ? { id: handle.id, distance } : current;
}, null);
return nearest && nearest.distance <= hitRadius ? nearest.id : undefined;
const columns = batchReferenceColumns(node.metadata?.batchTable);
let nearestIndex = -1;
let nearestDistance = Number.POSITIVE_INFINITY;
columns.forEach((_, index) => {
const distance = Math.abs(worldY - (node.position.y + BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP));
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
});
return nearestIndex >= 0 && nearestDistance <= hitRadius ? batchReferenceHandleId(columns[nearestIndex].id) : undefined;
}
export function removeLastBatchReferenceColumn(table: CanvasBatchTableData): CanvasBatchTableData | null {
const columns = batchReferenceColumns(table);
if (columns.length <= MIN_BATCH_REFERENCE_COLUMNS) return null;
const nextColumns = columns.slice(0, -1).map((column, index) => ({ ...column, label: `参考图 ${index + 1}` }));
return {
...table,
referenceColumns: nextColumns,
rows: table.rows.map((row) => ({ ...row, inputNodeIds: nextColumns.map((_, index) => row.inputNodeIds[index] || "") })),
};
}
export function reorderBatchReferenceColumns(table: CanvasBatchTableData, fromColumnId: string, toColumnId: string): CanvasBatchTableData {
const columns = batchReferenceColumns(table);
const fromIndex = columns.findIndex((column) => column.id === fromColumnId);
const toIndex = columns.findIndex((column) => column.id === toColumnId);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return table;
const nextColumns = [...columns];
const [moved] = nextColumns.splice(fromIndex, 1);
nextColumns.splice(toIndex, 0, moved);
return {
...table,
referenceColumns: nextColumns.map((column, index) => ({ ...column, label: `参考图 ${index + 1}` })),
rows: table.rows.map((row) => ({
...row,
inputNodeIds: nextColumns.map((column) => {
const oldIndex = columns.findIndex((item) => item.id === column.id);
return row.inputNodeIds[oldIndex] || "";
}),
})),
};
}
export function moveBatchReferenceCell(table: CanvasBatchTableData, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number): CanvasBatchTableData {
if (sourceRowId === targetRowId && sourceColumnIndex === targetColumnIndex) return table;
if (sourceColumnIndex < 0 || targetColumnIndex < 0) return table;
const referenceCount = Math.max(batchReferenceColumns(table).length, sourceColumnIndex + 1, targetColumnIndex + 1);
const sourceRow = table.rows.find((row) => row.id === sourceRowId);
const targetRow = table.rows.find((row) => row.id === targetRowId);
if (!sourceRow || !targetRow) return table;
const nextRows = table.rows.map((row) => ({ ...row, inputNodeIds: Array.from({ length: referenceCount }, (_, index) => row.inputNodeIds[index] || "") }));
const nextSource = nextRows.find((row) => row.id === sourceRowId)!;
const nextTarget = nextRows.find((row) => row.id === targetRowId)!;
const sourceNodeId = nextSource.inputNodeIds[sourceColumnIndex];
if (!sourceNodeId) return table;
const targetNodeId = nextTarget.inputNodeIds[targetColumnIndex];
nextTarget.inputNodeIds[targetColumnIndex] = sourceNodeId;
nextSource.inputNodeIds[sourceColumnIndex] = targetNodeId || "";
return { ...table, rows: nextRows };
}
export function batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchRow) {
return table.globalPrompt?.trim() || row.prompt;
}
export function batchInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) {
@@ -181,34 +135,20 @@ export function batchInputColumns(node: CanvasNodeData, connections: CanvasConne
return result;
}
export function batchTextInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) {
const columns = batchTextColumns(node.metadata?.batchTable);
const indexById = new Map(columns.map((column, index) => [column.id, index]));
const result = columns.map(() => [] as string[]);
connections.filter((connection) => connection.toNodeId === node.id && connection.relation !== "batch-output").forEach((connection) => {
const columnId = batchTextColumnId(connection.toHandleId);
const index = columnId ? indexById.get(columnId) : undefined;
if (index === undefined || result[index].includes(connection.fromNodeId)) return;
result[index].push(connection.fromNodeId);
});
return result;
}
/** A non-empty global prompt takes precedence without destroying row-specific prompts. */
export function batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchRow) {
return table.globalPrompt?.trim() || row.prompt;
}
export function createBatchRow(operation: CanvasBatchOperation, inputNodeIds: string[] = []): CanvasBatchRow {
return {
id: `batch-row-${nanoid()}`,
enabled: true,
inputNodeIds,
textNodeIds: [],
prompt: operation === "try_on" ? TRY_ON_BATCH_PROMPT : CREATIVE_BATCH_PROMPT,
};
}
/** 手工追加任务时沿用上一行的参考图,但重新使用当前任务类型的默认提示词。 */
export function createInheritedBatchRow(operation: CanvasBatchOperation, rows: CanvasBatchRow[]) {
return createBatchRow(operation, [...(rows.at(-1)?.inputNodeIds || [])]);
}
/**
* Connected try-on inputs follow the reference layout: all model/person images first,
* followed by one shared garment image. Users can still edit any row afterwards.
@@ -220,32 +160,34 @@ export function createBatchRowsFromInputs(operation: CanvasBatchOperation, input
return inputNodeIds.slice(0, -1).map((personId) => createBatchRow(operation, [personId, garmentId]));
}
/** Zip equally sized columns and broadcast singleton columns across every row. */
/**
* Zip equally sized columns and broadcast singleton columns across every row.
*
* 同步连线是增量操作:匹配到的行更新参考图,未被本次连线覆盖的手工行继续保留。
* 这样用户在表格内追加或编辑的任务不会因为再次点击“同步连线”而被删除。
*/
export function createBatchRowsFromColumns(operation: CanvasBatchOperation, columns: string[][], previousRows: CanvasBatchRow[] = []) {
const rowCount = Math.max(0, ...columns.map((column) => column.length));
const previousByPrimary = new Map(previousRows.flatMap((row) => row.inputNodeIds[0] ? [[row.inputNodeIds[0], row] as const] : []));
return Array.from({ length: rowCount }, (_, rowIndex) => {
const unmatchedRows = [...previousRows];
const synchronizedRows = Array.from({ length: rowCount }, (_, rowIndex) => {
const inputNodeIds = columns.flatMap((column) => {
const input = column.length === 1 ? column[0] : column[rowIndex];
return input ? [input] : [];
});
const previous = inputNodeIds[0] ? previousByPrimary.get(inputNodeIds[0]) : undefined;
let previousIndex = unmatchedRows.findIndex((row) => sameBatchInputs(row.inputNodeIds, inputNodeIds));
if (previousIndex < 0 && inputNodeIds[0]) previousIndex = unmatchedRows.findIndex((row) => row.inputNodeIds[0] === inputNodeIds[0]);
const previous = previousIndex >= 0 ? unmatchedRows.splice(previousIndex, 1)[0] : undefined;
const inputsUnchanged = previous && previous.inputNodeIds.length === inputNodeIds.length && previous.inputNodeIds.every((id, index) => id === inputNodeIds[index]);
return {
...createBatchRow(operation, inputNodeIds),
...(previous ? { id: previous.id, enabled: previous.enabled, prompt: previous.prompt, textNodeIds: previous.textNodeIds } : {}),
...(previous ? { id: previous.id, enabled: previous.enabled, prompt: previous.prompt } : {}),
...(inputsUnchanged && previous?.outputNodeId ? { outputNodeId: previous.outputNodeId } : {}),
inputNodeIds,
};
});
return [...synchronizedRows, ...unmatchedRows];
}
export function assignBatchTextRows(rows: CanvasBatchRow[], columns: string[][]) {
return rows.map((row, rowIndex) => ({
...row,
textNodeIds: columns.flatMap((column) => {
const input = column.length === 1 ? column[0] : column[rowIndex];
return input ? [input] : [];
}),
}));
function sameBatchInputs(left: string[], right: string[]) {
return left.length === right.length && left.every((id, index) => id === right[index]);
}

View File

@@ -135,7 +135,20 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD
const children = (root.metadata.batchChildIds || [])
.map((id) => nodeById.get(id))
.filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Image && node.metadata?.batchRootId === root.id));
if (!children.length) return root;
if (!children.length) {
const metadata: CanvasNodeMetadata = { ...root.metadata };
delete metadata.batchChildIds;
delete metadata.batchFailedCount;
delete metadata.primaryImageId;
delete metadata.isBatchRoot;
delete metadata.imageBatchExpanded;
delete metadata.errorDetails;
delete metadata.generationErrorCode;
delete metadata.resourceReloadAvailable;
delete metadata.failedPromptFingerprint;
if (!metadata.content) metadata.status = "idle";
return { ...root, metadata };
}
const primary = children.find((node) => node.id === root.metadata?.primaryImageId && node.metadata?.content) || children.find((node) => node.metadata?.content);
const loading = children.some((node) => node.metadata?.status === "loading");

View File

@@ -10,6 +10,7 @@ import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { canvasNodeMentionToken, canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { batchReferenceHandleY } from "@/lib/canvas/canvas-batch-table";
import { reconcileImageBatchRoot } from "@/lib/canvas/canvas-image-batch-retry";
import { scopedLocalStorage } from "@/lib/user-scope";
import type { GenerationTask } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
@@ -430,7 +431,7 @@ export function removeCanvasNodes(nodes: CanvasNodeData[], requestedIds: Set<str
if (requestedIds.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => removedIds.add(childId));
});
const remainingNodes = nodes.filter((node) => !removedIds.has(node.id));
const nextNodes = remainingNodes.map((node) => {
const cleanedNodes = remainingNodes.map((node) => {
const detached = node.parentId && removedIds.has(node.parentId) ? { ...node, parentId: undefined } : node;
const storyboard = detached.metadata?.storyboard;
const cleaned = storyboard
@@ -458,6 +459,7 @@ export function removeCanvasNodes(nodes: CanvasNodeData[], requestedIds: Set<str
const batchRoot = { ...cleaned, metadata: { ...cleaned.metadata, batchChildIds: childIds, primaryImageId } };
return primaryNode ? applyBatchPrimaryImage(batchRoot, primaryNode) : batchRoot;
});
const nextNodes = cleanedNodes.map((node) => reconcileImageBatchRoot(node, cleanedNodes));
return { removedIds, nodes: nextNodes };
}

View File

@@ -154,14 +154,7 @@ const BUILTIN_NODE_TRAITS = {
showInCreateMenu: true,
inputKind: "text",
},
[CanvasNodeType.BatchTable]: {
label: "批量创作表",
icon: <Table2 />,
minSize: { width: 720, height: 360 },
showInCreateMenu: true,
acceptsInputKind: ["image", "text"],
inputKind: "text",
},
[CanvasNodeType.BatchTable]: { label: "批量创作表", icon: <Table2 />, minSize: { width: 1080, height: 420 }, showInCreateMenu: true, acceptsInputKind: "image", inputKind: "text", resourceKind: () => null },
[CanvasNodeType.MediaConversion]: { label: "转换", icon: <WandSparkles />, minSize: { width: 400, height: 360 }, showInCreateMenu: true, resourceKind: (node: CanvasNodeData) => { const c = node.metadata?.mediaConversion; return c?.status === "completed" && c.resultStorageKey ? (c.outputKind === "video" ? "video" : "image") : null; }, acceptsInputKind: ["image", "video"], maxInputCount: 1, inputKind: "image" },
[CanvasNodeType.ColorGrade]: {
label: "调色",