@@ -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_GA P = 38 ;
export const MAX_ BATCH_REFERENCE_COLUMNS = 1 0;
export const MAX _BATCH_TEXT _COLUMNS = 4 ;
export const BATCH_TEXT_HANDLE_PREFIX = "batch-text:" ;
/** 两行工具栏(操作栏 44 + 全局提示词 36 + 分隔 1) 之后, 对准表头中线。 */
export const BATCH_REFERENCE_HANDLE_TO P = 102 ;
export const BATCH_REFERENCE_HANDLE_GAP = 4 0;
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 referenceC olumns = batchReferenceColumns ( node . metadata ? . batchTable ) ;
const textColumns = batchTextColumns ( node . metadata ? . batchTable ) ;
const handles = [
. . . referenceC olumns . 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. d istance <= hitRadius ? nearest.id : undefined;
const c olumns = batchReferenceColumns ( node . metadata ? . batchTable ) ;
let nearestIndex = - 1 ;
let nearestDistance = Number . POSITIVE_INFINITY ;
c olumns. 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 && nearestD istance <= 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 ] : [ ] ;
} ) ;
cons t previous = inputNodeIds [ 0 ] ? previousByPrimary . get ( inputNodeIds [ 0 ] ) : undefined ;
le t 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 , rowI ndex) = > ( {
. . . 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 , i ndex) = > id === right [ index ] ) ;
}