Files
ttstd-infinite-canvas/backend/internal/canvas/capability/builtin.go

254 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package capability
import "yingce/backend/internal/canvas/contract"
const (
maxAgentNodeTitleRunes = 240
maxAgentNodeContentRunes = 16000
// 坐标绝对值上限:与 app 侧整理工具收敛几何值的范围一致。
maxAgentNodeCoordLimit = 1e6
)
func BuiltinRegistry() *Registry {
registry, err := NewRegistry([]Descriptor{
{
Type: "text", Version: "1", Label: "文本", DefaultWidth: 340, DefaultHeight: 240,
Purpose: "承载普通说明、创意草稿和单段提示词。",
GoodFor: []string{"单个创意", "一次性提示词", "临时备注", "快速试验"},
NotIdealFor: []string{"多镜头脚本", "需要逐镜修改的内容", "需要镜头级资产关系的内容"},
Tradeoffs: []string{"创建和编辑最轻量", "没有镜头级字段和逐镜维护能力"},
InputKind: "text", Connection: ConnectionPolicy{CanSource: true}, CanUpdate: true,
SummaryFields: []string{"content"}, DetailFields: []string{"content"},
PatchFields: editableNodeFields("metadata.content", "正文", "节点正文"),
CreateMetadata: func(content string) map[string]any {
return map[string]any{"content": content, "status": "idle", "fontSize": float64(14)}
},
},
{
Type: "markdown", Version: "1", Label: "Markdown", DefaultWidth: 420, DefaultHeight: 320,
Purpose: "承载面向人阅读的方案、脚本草稿和格式化文档。",
GoodFor: []string{"创意方案", "脚本草稿", "交付文档", "需要排版的长文本"},
NotIdealFor: []string{"需要逐镜生成或审核的多镜头内容", "需要绑定媒体资产的结构化流程"},
Tradeoffs: []string{"适合阅读和导出", "结构化程度低,不能替代可维护的分镜表"},
InputKind: "text", Connection: ConnectionPolicy{CanSource: true}, CanUpdate: true,
SummaryFields: []string{"content"}, DetailFields: []string{"content"},
PatchFields: editableNodeFields("metadata.content", "Markdown 正文", "Markdown 正文"),
},
{
Type: "file", Version: "1", Label: "文本文件", DefaultWidth: 420, DefaultHeight: 320,
Purpose: "承载上传到画布的 TXT、Markdown 或其它文件资源;正文通过 canvas_read_text 在当前用户权限内读取。",
GoodFor: []string{"读取上传小说或剧本原文", "保留原始文件作为改编来源"},
NotIdealFor: []string{"图片、视频或音频参考", "需要逐镜维护的结构化分镜"},
Tradeoffs: []string{"文件正文按需读取并分页返回", "二进制文件不会被当作文本解析"},
InputKind: "text", Connection: ConnectionPolicy{CanSource: true},
SummaryFields: []string{"mimeType"}, DetailFields: []string{"mimeType"},
},
generatedMediaDescriptor("image", "2", "图片", 720, 405, "image", ConnectionPolicy{
CanSource: true, CanTarget: true, CanReference: true, AcceptedInputKinds: []string{"text", "image", "character"},
}),
generatedMediaDescriptor("video", "2", "视频", 720, 405, "video", ConnectionPolicy{
CanSource: true, CanTarget: true, CanReference: true, AcceptedInputKinds: []string{"text", "image", "video", "audio", "character"},
}),
generatedMediaDescriptor("audio", "2", "音频", 340, 120, "audio", ConnectionPolicy{
CanSource: true, CanTarget: true, CanReference: true, MaxInputCount: 1, AcceptedInputKinds: []string{"text", "character"},
}),
characterDescriptor(),
{
Type: "frame", Version: "1", Label: "背板", DefaultWidth: 760, DefaultHeight: 520,
Purpose: "在画布上建立可移动、可折叠的视觉分区,用来归组相关节点;背板本身不承载创作正文或生成结果。",
GoodFor: []string{"按场景或镜头组归拢脚本、参考图和生成结果", "按前期策划、制作、交付等阶段划分工作区", "为大型画布建立清晰分区,便于移动或折叠整组内容"},
NotIdealFor: []string{"承载结构化镜头数据(应使用分镜脚本节点)", "存放需要 Agent 单独读写的正文(应使用文本或 Markdown 节点)", "替代具体业务节点或媒体生成节点"},
Tradeoffs: []string{"改善空间组织但不增加内容结构或生成能力"},
SummaryFields: []string{"label"}, DetailFields: []string{"label"},
CreateMetadata: func(string) map[string]any {
return map[string]any{"frame": map[string]any{"collapsed": false, "expandedWidth": float64(760), "expandedHeight": float64(520)}}
},
},
{
Type: "batch-table", Version: "1", Label: "批量创作表", DefaultWidth: 1280, DefaultHeight: 560,
Purpose: "面向电商批量换装和创意生图的结构化任务表;每行绑定最多六组画布图片、可用 @参考图1 等位置引用编写独立提示词,也可设置全局提示词覆盖各行,并追踪生成结果。",
GoodFor: []string{"商品与模特批量换装", "同一商品多场景创意图", "多组参考图组合生成", "批量结果追踪与失败重试"},
NotIdealFor: []string{"通用数据库或库存管理", "单张图片快速试验", "多镜头叙事连续性"},
Tradeoffs: []string{"参考图必须先作为图片节点进入画布", "批量提交会产生多项生成任务,执行前必须确认模型、数量和费用"},
Actions: []string{"read_rows", "append_row", "update_row", "remove_row", "set_operation", "set_concurrency", "add_reference_column", "remove_reference_column", "set_global_prompt", "preview_batch_generation"},
InputKind: "text",
Connection: ConnectionPolicy{CanSource: true, CanTarget: true, AcceptedInputKinds: []string{"image"}},
CanUpdate: true, SummaryFields: []string{"batchTable"}, DetailFields: []string{"batchTable"}, ProjectionKind: "batch_table", ProjectionField: "batchTable",
PatchFields: func() map[string]PatchField {
fields := map[string]PatchField{
"title": {Path: "title", Kind: patchKindString, Label: "节点名称", Order: 10, Description: "批量创作表标题", MaxRunes: maxAgentNodeTitleRunes},
}
for key, field := range positionPatchFields() {
fields[key] = field
}
return fields
}(),
CreateMetadata: func(string) map[string]any {
return map[string]any{
"status": "idle",
"batchTable": map[string]any{
"operation": "try_on", "concurrency": float64(10),
"referenceColumns": []any{
map[string]any{"id": "reference-1", "label": "参考图 1"},
map[string]any{"id": "reference-2", "label": "参考图 2"},
map[string]any{"id": "reference-3", "label": "参考图 3"},
},
"rows": []any{},
},
}
},
},
{
Type: "script", Version: "1", Label: "分镜脚本", DefaultWidth: 920, DefaultHeight: 360,
Purpose: "维护结构化的多镜头脚本,支持镜头级审查、修改和媒体关联。",
GoodFor: []string{"多镜头规划", "镜头连续性", "逐镜审查和微调", "逐镜生成图片或视频", "需要他人接手维护的内容"},
NotIdealFor: []string{"只有一个画面的快速试验", "一次性临时提示词", "仅需要阅读排版的普通文档"},
Tradeoffs: []string{"前期录入成本高于文本节点", "但能保留镜头级结构、资产关系和后续维护能力"},
Actions: []string{"read_rows", "append_row", "update_row", "remove_row", "generate_storyboard", "connect_assets", "update_node"},
InputKind: "text",
Connection: ConnectionPolicy{CanSource: true, CanTarget: true, AcceptedInputKinds: []string{"audio", "character", "image", "text", "video"}},
CanUpdate: true,
SummaryFields: []string{"storyboard"}, DetailFields: []string{"storyboard"}, ProjectionKind: "storyboard", ProjectionField: "storyboard",
PatchFields: titleAndPositionPatchFields(),
CreateMetadata: func(string) map[string]any {
return map[string]any{"status": "idle", "workflowKind": "script", "storyboard": map[string]any{"rows": []any{}, "visibleColumns": []any{"shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"}, "referenceNodeIds": []any{}}}
},
},
})
if err != nil {
panic(err)
}
return registry
}
// characterDescriptor 是角色卡能力:画布上是 text + metadata.workflowKind=character 的节点,
// 设定、三视图与声音都来自账号内的角色资产而非节点正文。它可以被发现、精读、连线和引用,
// 但不能用 add_node 凭空创建:必须经 canvas_create_character 用真实图片/音频建角色资产,名称和设定只随角色资产更新。
func characterDescriptor() Descriptor {
return Descriptor{
Type: "character", Version: "1", Label: "角色卡", DefaultWidth: 264, DefaultHeight: 352,
Purpose: "引用账号角色库中的角色资产,统一提供角色设定、三视图/形象图和绑定声音,用来保持人物在多次生成中的一致性。",
GoodFor: []string{"图片或视频生成时锁定人物外观", "多镜头保持同一角色一致", "角色配音时使用绑定声音", "只取角色文字设定作为提示词来源"},
NotIdealFor: []string{"临时一次性的人物描述(直接写进提示词或文本节点)", "用 add_node 新建(应使用 canvas_create_character 打包形象图片与声音)", "承载普通正文(节点正文不是角色设定)"},
Tradeoffs: []string{"设定与媒体随角色资产版本变化,提交时会校验版本", "未绑定形象或声音时对应引用不可用,需先读取 character.imageReference/audioReference"},
Actions: []string{"read_character", "use_as_reference", "use_as_text_source"},
InputKind: "character",
Connection: ConnectionPolicy{CanSource: true, CanReference: true},
CanUpdate: true,
PatchFields: positionPatchFields(),
Variant: &NodeVariant{BaseType: "text", WorkflowKind: "character"},
}
}
// generatedMediaDescriptor 根据节点类型、显示尺寸、生成模式和连接规则创建媒体能力描述。
// 返回读写字段及创建默认值;首尾帧只对视频开放,避免其他节点接受无效视频参数。
func generatedMediaDescriptor(nodeType, version, label string, width, height float64, generationMode string, connection ConnectionPolicy) Descriptor {
semantics := generatedMediaSemantics(nodeType)
fields := editableNodeFields("metadata.generationSpec.prompt", "提示词", "生成合同中的当前提示词;这是 Agent 唯一读写的媒体提示词")
readFields := []string{"prompt", "assetTags", "referenceNodeIds"}
if generationMode == "video" {
for i, frame := range []struct{ key, label string }{
{"videoStartFrameNodeId", "首帧"}, {"videoEndFrameNodeId", "尾帧"},
} {
fields[frame.key] = PatchField{Path: "metadata." + frame.key, Kind: patchKindString, Label: frame.label, Order: 40 + i, MaxRunes: 80, Description: "已连接图片ID;空字符串取消"}
readFields = append(readFields, frame.key)
}
}
return Descriptor{
Type: nodeType, Version: version, Label: label, DefaultWidth: width, DefaultHeight: height,
Purpose: semantics.Purpose, GoodFor: semantics.GoodFor, NotIdealFor: semantics.NotIdealFor,
Tradeoffs: semantics.Tradeoffs, Actions: semantics.Actions,
InputKind: nodeType, GenerationMode: generationMode, Connection: connection, CanUpdate: true,
SummaryFields: readFields,
DetailFields: readFields,
PatchFields: fields,
CreateMetadata: func(prompt string) map[string]any { return generatedMetadata(generationMode, prompt) },
}
}
type generatedMediaCapabilitySemantics struct {
Purpose string
GoodFor []string
NotIdealFor []string
Tradeoffs []string
Actions []string
}
func generatedMediaSemantics(nodeType string) generatedMediaCapabilitySemantics {
switch nodeType {
case "image":
return generatedMediaCapabilitySemantics{
Purpose: "生成或承载一个静态画面,并作为后续图片或视频生成的真实参考素材。",
GoodFor: []string{"单张图片生成", "有参考图的图片生成", "分镜首帧和关键帧", "需要复用的视觉素材"},
NotIdealFor: []string{"承载多镜头脚本结构", "表达镜头运动或时间变化", "代替分镜表维护镜头连续性"},
Tradeoffs: []string{"一个节点对应一个静态媒体目标", "参考图数量和生成方式必须再由模型目录能力匹配"},
Actions: []string{"generate_media", "update_prompt", "use_as_reference"},
}
case "video":
return generatedMediaCapabilitySemantics{
Purpose: "生成或承载一个连续视频片段;可按实际参考素材执行文生视频、图生视频或多图生视频。",
GoodFor: []string{"单镜头文生视频", "单图生视频", "多图参考视频", "已有视频或音频参与的视频生成"},
NotIdealFor: []string{"承载整部多镜头脚本", "用一个节点代替逐镜审查和维护", "未查询模型能力就假定支持任意参考数量"},
Tradeoffs: []string{"一个节点通常对应一个可独立生成和审核的视频片段", "参考类型、数量、时长、画幅、音频和价格受当前模型目录约束"},
Actions: []string{"generate_media", "update_prompt", "use_as_reference"},
}
case "audio":
return generatedMediaCapabilitySemantics{
Purpose: "生成或承载一个音频素材,用于配音、音乐、音效或视频参考输入。",
GoodFor: []string{"文本转语音", "配音", "音乐或音效素材", "为视频提供音频参考"},
NotIdealFor: []string{"承载分镜结构", "表达画面构图或镜头运动", "代替视频节点"},
Tradeoffs: []string{"一个节点对应一个音频目标且最多接收一个文本输入", "音频类型、时长和价格仍以模型目录及任务准入为准"},
Actions: []string{"generate_media", "update_prompt", "use_as_reference"},
}
default:
return generatedMediaCapabilitySemantics{}
}
}
func titleAndPositionPatchFields() map[string]PatchField {
fields := map[string]PatchField{
"title": {
Path: "title", Kind: patchKindString, Label: "节点名称", Order: 10, Description: "节点标题", MaxRunes: maxAgentNodeTitleRunes,
},
}
for key, field := range positionPatchFields() {
fields[key] = field
}
return fields
}
func editableNodeFields(contentPath, contentLabel, contentDescription string) map[string]PatchField {
fields := titleAndPositionPatchFields()
fields["content"] = PatchField{
Path: contentPath, Kind: patchKindString, Label: contentLabel, Order: 20, Description: contentDescription, MaxRunes: maxAgentNodeContentRunes,
}
return fields
}
// positionPatchFields 是坐标字段:模型可以直接指定节点位置(微调),批量整理走 canvas_arrange_nodes。
// 坐标上限与整理侧的收敛范围一致,避免写进离谱的几何值。
func positionPatchFields() map[string]PatchField {
return map[string]PatchField{
"x": {Path: "position.x", Kind: patchKindNumber, Label: "横坐标", Order: 30, Limit: maxAgentNodeCoordLimit, Description: "画布横坐标(像素);与 y 一起移动节点,通常用 canvas_arrange_nodes 批量整理"},
"y": {Path: "position.y", Kind: patchKindNumber, Label: "纵坐标", Order: 31, Limit: maxAgentNodeCoordLimit, Description: "画布纵坐标(像素);与 x 一起移动节点,通常用 canvas_arrange_nodes 批量整理"},
}
}
func generatedMetadata(mode, prompt string) map[string]any {
spec := contract.GenerationSpec{
Version: contract.GenerationVersion,
Mode: mode,
Prompt: prompt,
Options: contract.Options{},
ReferenceBindings: []contract.ReferenceBinding{},
TextInputMode: "append-sources",
}
metadata, err := spec.NodeMetadata()
if err != nil {
panic(err)
}
metadata["content"] = ""
metadata["status"] = "idle"
return metadata
}