Files
ttstd-infinite-canvas/backend/internal/canvas/contract/generation.go

318 lines
12 KiB
Go

// Package contract owns the credential-free canvas generation contract.
// Provider configuration remains an execution concern; it is never persisted here.
package contract
import (
"bytes"
"encoding/json"
"fmt"
"io"
"reflect"
"strconv"
"strings"
"unicode/utf8"
)
//go:generate go run ./generate
const (
GenerationVersion = 1
MaxNodeTitleRunes = 240
MaxPromptRunes = 16000
MaxCanvasOperations = 20
)
type ModelSelection struct {
Kind string `json:"kind" enum:"logical,channel"`
LogicalModelID string `json:"logicalModelId,omitempty"`
ChannelID string `json:"channelId,omitempty"`
ModelKey string `json:"modelKey,omitempty"`
}
// Options contains public user choices, not resolved provider configuration.
// node/task tags are the only mapping between the persisted canvas and task wire names.
type Options struct {
Size *string `json:"size,omitempty" node:"size" task:"size" modes:"image,video"`
Quality *string `json:"quality,omitempty" node:"quality" task:"quality" modes:"image"`
Count *int `json:"count,omitempty" node:"count" task:"count" modes:"image"`
TransparentBackground *bool `json:"transparentBackground,omitempty" node:"transparentBackground" task:"transparentBackground" modes:"image"`
DurationSeconds *int `json:"durationSeconds,omitempty" node:"seconds" task:"videoSeconds" modes:"video"`
Resolution *string `json:"resolution,omitempty" node:"vquality" task:"vquality" modes:"video"`
GenerateAudio *bool `json:"generateAudio,omitempty" node:"generateAudio" task:"videoGenerateAudio" modes:"video"`
Watermark *bool `json:"watermark,omitempty" node:"watermark" task:"videoWatermark" modes:"video"`
AudioVoice *string `json:"audioVoice,omitempty" node:"audioVoice" task:"audioVoice" modes:"audio"`
AudioFormat *string `json:"audioFormat,omitempty" node:"audioFormat" task:"audioFormat" modes:"audio"`
AudioSpeed *string `json:"audioSpeed,omitempty" node:"audioSpeed" task:"audioSpeed" modes:"audio"`
AudioInstructions *string `json:"audioInstructions,omitempty" node:"audioInstructions" task:"audioInstructions" modes:"audio"`
}
type ReferenceBinding struct {
ID string `json:"id"`
NodeID string `json:"nodeId,omitempty"`
ResourceID string `json:"resourceId,omitempty"`
TransientID string `json:"transientId,omitempty"`
MediaType string `json:"mediaType" enum:"image,video,audio,text"`
Role string `json:"role" enum:"reference,source-text,first-frame,last-frame,mask"`
Order int `json:"order"`
Resolution string `json:"resolution" enum:"latest,snapshot"`
}
type GenerationSpec struct {
Version int `json:"version"`
Mode string `json:"mode" enum:"image,video,audio"`
Prompt string `json:"prompt"`
ModelSelection *ModelSelection `json:"modelSelection,omitempty"`
Options Options `json:"options"`
ReferenceBindings []ReferenceBinding `json:"referenceBindings"`
TextInputMode string `json:"textInputMode" enum:"prompt-only,append-sources"`
}
type FieldError struct{ Path, Message string }
func (e *FieldError) Error() string { return e.Path + ": " + e.Message }
func invalid(path, message string) error { return &FieldError{Path: path, Message: message} }
func (s ModelSelection) Validate() error {
switch s.Kind {
case "logical":
if strings.TrimSpace(s.LogicalModelID) == "" || s.ChannelID != "" || s.ModelKey != "" {
return invalid("modelSelection", "逻辑模型必须且只能指定 logicalModelId")
}
case "channel":
if strings.TrimSpace(s.ChannelID) == "" || strings.TrimSpace(s.ModelKey) == "" || s.LogicalModelID != "" {
return invalid("modelSelection", "渠道模型必须且只能指定 channelId 和 modelKey")
}
default:
return invalid("modelSelection.kind", "未知模型选择类型")
}
return nil
}
func (s GenerationSpec) Validate() error {
if s.Version != GenerationVersion {
return invalid("generationSpec.version", "生成合同版本不受支持")
}
if s.Mode != "image" && s.Mode != "video" && s.Mode != "audio" {
return invalid("generationSpec.mode", "未知生成模式")
}
if utf8.RuneCountInString(s.Prompt) > MaxPromptRunes {
return invalid("generationSpec.prompt", "提示词超过长度限制")
}
if s.ModelSelection != nil {
if err := s.ModelSelection.Validate(); err != nil {
return err
}
}
if s.TextInputMode != "prompt-only" && s.TextInputMode != "append-sources" {
return invalid("generationSpec.textInputMode", "未知文本输入模式")
}
value, typ := reflect.ValueOf(s.Options), reflect.TypeOf(s.Options)
for i := 0; i < typ.NumField(); i++ {
if value.Field(i).IsNil() {
continue
}
field := typ.Field(i)
if !strings.Contains(","+field.Tag.Get("modes")+",", ","+s.Mode+",") {
return invalid("options."+strings.Split(field.Tag.Get("json"), ",")[0], "该选项不适用于当前生成模式")
}
}
if s.Options.DurationSeconds != nil && *s.Options.DurationSeconds < 0 {
return invalid("options.durationSeconds", "时长不能为负数")
}
if s.Options.Count != nil && *s.Options.Count < 1 {
return invalid("options.count", "数量必须为正整数")
}
ids := map[string]bool{}
orders := map[int]bool{}
for i, binding := range s.ReferenceBindings {
path := fmt.Sprintf("referenceBindings[%d]", i)
if binding.ID == "" || ids[binding.ID] || binding.Order < 0 || orders[binding.Order] {
return invalid(path, "引用身份和顺序必须有效且唯一")
}
ids[binding.ID], orders[binding.Order] = true, true
if binding.NodeID == "" && binding.ResourceID == "" && binding.TransientID == "" {
return invalid(path, "引用缺少来源")
}
if binding.TransientID != "" && (binding.NodeID != "" || binding.ResourceID != "") {
return invalid(path, "临时引用不能混用其他来源")
}
if binding.Resolution != "latest" && binding.Resolution != "snapshot" {
return invalid(path+".resolution", "未知引用解析策略")
}
if binding.MediaType != "image" && binding.MediaType != "video" && binding.MediaType != "audio" && binding.MediaType != "text" {
return invalid(path+".mediaType", "未知参考类型")
}
if binding.Role != "reference" && binding.Role != "source-text" && binding.Role != "first-frame" && binding.Role != "last-frame" && binding.Role != "mask" {
return invalid(path+".role", "未知参考角色")
}
if binding.Role == "source-text" && binding.MediaType != "text" {
return invalid(path+".role", "文本来源必须引用文本")
}
if (binding.Role == "first-frame" || binding.Role == "last-frame" || binding.Role == "mask") && binding.MediaType != "image" {
return invalid(path+".role", "帧和遮罩必须引用图片")
}
}
return nil
}
func Decode(data []byte) (GenerationSpec, error) {
var spec GenerationSpec
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&spec); err != nil {
return spec, invalid("generationSpec", err.Error())
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return spec, invalid("generationSpec", "必须是单个对象")
}
return spec, spec.Validate()
}
// OptionsFromTaskConfig only copies public fields declared above. Credentials and
// provider routing fields cannot be admitted by adding a task config key.
func OptionsFromTaskConfig(mode string, config map[string]any) (Options, error) {
options := Options{}
value, typ := reflect.ValueOf(&options).Elem(), reflect.TypeOf(options)
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if !strings.Contains(","+field.Tag.Get("modes")+",", ","+mode+",") {
continue
}
raw, exists := config[field.Tag.Get("task")]
if !exists || raw == nil || raw == "" {
continue
}
parsed := reflect.New(field.Type.Elem())
switch field.Type.Elem().Kind() {
case reflect.String:
text, ok := raw.(string)
if !ok {
return options, invalid("options."+field.Tag.Get("task"), "必须是字符串")
}
parsed.Elem().SetString(text)
case reflect.Bool:
boolean, err := strconv.ParseBool(fmt.Sprint(raw))
if err != nil {
return options, invalid("options."+field.Tag.Get("task"), "必须是布尔值")
}
parsed.Elem().SetBool(boolean)
case reflect.Int:
number, err := strconv.Atoi(fmt.Sprint(raw))
if err != nil {
return options, invalid("options."+field.Tag.Get("task"), "必须是整数")
}
parsed.Elem().SetInt(int64(number))
}
value.Field(i).Set(parsed)
}
return options, nil
}
func (o Options) TaskConfig() map[string]any {
result := map[string]any{}
value, typ := reflect.ValueOf(o), reflect.TypeOf(o)
for i := 0; i < typ.NumField(); i++ {
if !value.Field(i).IsNil() {
result[typ.Field(i).Tag.Get("task")] = fmt.Sprint(value.Field(i).Elem().Interface())
}
}
return result
}
// NodeMetadata projects the editable specification into the existing canvas wire
// format. The mirror fields keep existing renderers working; all conversions live here.
func (s GenerationSpec) NodeMetadata() (map[string]any, error) {
if err := s.Validate(); err != nil {
return nil, err
}
encoded, err := json.Marshal(s)
if err != nil {
return nil, err
}
var stored map[string]any
if err := json.Unmarshal(encoded, &stored); err != nil {
return nil, err
}
result := map[string]any{"generationSpec": stored, "prompt": s.Prompt, "composerContent": s.Prompt}
value, typ := reflect.ValueOf(s.Options), reflect.TypeOf(s.Options)
for i := 0; i < typ.NumField(); i++ {
if value.Field(i).IsNil() {
continue
}
fieldValue := value.Field(i).Elem().Interface()
if typ.Field(i).Tag.Get("node") != "count" {
fieldValue = fmt.Sprint(fieldValue)
}
result[typ.Field(i).Tag.Get("node")] = fieldValue
}
if selection := s.ModelSelection; selection != nil {
if selection.Kind == "logical" {
result["logicalModelId"] = selection.LogicalModelID
} else {
result["channelId"], result["channelModelKey"] = selection.ChannelID, selection.ModelKey
result["model"] = selection.ChannelID + "::" + selection.ModelKey
}
}
return result, nil
}
// NodeGenerationProjection is the explicit dependency read-set of generation.
// Locks, task binding and authorization are checked separately by the caller.
//
// The projection keeps the canonical contract fields and also carries stable
// extension metadata. Canvas presentation fields are deliberately excluded so
// moving/resizing a node does not invalidate a prepared generation, while a
// newly-added generation input cannot silently bypass admission.
func NodeGenerationProjection(metadata map[string]any) (map[string]any, error) {
result := map[string]any{}
if raw, exists := metadata["generationSpec"]; exists {
encoded, err := json.Marshal(raw)
if err != nil {
return nil, err
}
spec, err := Decode(encoded)
if err != nil {
return nil, err
}
result["generationSpec"] = spec
}
known := map[string]struct{}{
"generationSpec": {}, "prompt": {}, "composerContent": {}, "model": {},
"logicalModelId": {}, "channelId": {}, "channelModelKey": {},
"videoStartFrameNodeId": {}, "videoEndFrameNodeId": {},
"videoEditOperation": {}, "referenceNodeIds": {},
}
typ := reflect.TypeOf(Options{})
for i := 0; i < typ.NumField(); i++ {
known[typ.Field(i).Tag.Get("node")] = struct{}{}
}
for key, value := range metadata {
if _, exists := known[key]; exists {
if key != "generationSpec" {
result[key] = value
}
continue
}
if isCanvasPresentationMetadataKey(key) {
continue
}
extensions, _ := result["extensions"].(map[string]any)
if extensions == nil {
extensions = map[string]any{}
result["extensions"] = extensions
}
extensions[key] = value
}
return result, nil
}
func isCanvasPresentationMetadataKey(key string) bool {
switch key {
case "position", "x", "y", "width", "height", "createdAt", "updatedAt", "selected", "collapsed", "zIndex", "color", "background", "displayName", "label", "ui", "presentation", "layout", "screenPosition", "isSelected", "dragging", "resizing":
return true
default:
return false
}
}