176 lines
8.0 KiB
Go
176 lines
8.0 KiB
Go
package workbench
|
||
|
||
import (
|
||
_ "embed"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"yingce/backend/internal/model"
|
||
"yingce/backend/internal/repository"
|
||
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// This resource-free snapshot comes from scripts/workbench-catalog-seed.ts.
|
||
// Keep keys stable; display text and ordering are not part of record identity.
|
||
//
|
||
//go:embed seed/catalog.json
|
||
var builtinCatalogJSON []byte
|
||
|
||
type builtinToolSeed struct {
|
||
Key string `json:"key"`
|
||
Kind string `json:"kind"`
|
||
CategoryID string `json:"categoryId"`
|
||
Name string `json:"name"`
|
||
Focus string `json:"focus"`
|
||
InputOne string `json:"inputOne"`
|
||
InputTwo string `json:"inputTwo"`
|
||
}
|
||
|
||
// EnsureBuiltinCatalog seeds missing records without changing administrator
|
||
// edits, versions, timestamps, or publication state of existing records.
|
||
func (s *Service) EnsureBuiltinCatalog() error {
|
||
tools, presets, err := builtinCatalog()
|
||
if err != nil {
|
||
return fmt.Errorf("prepare builtin workbench catalog: %w", err)
|
||
}
|
||
return s.repo.Transaction(func(repo *repository.Repository) error {
|
||
for index := range tools {
|
||
if err := repo.CreateWorkbenchToolIfMissing(&tools[index]); err != nil {
|
||
return fmt.Errorf("seed workbench tool %s: %w", tools[index].ID, err)
|
||
}
|
||
}
|
||
for index := range presets {
|
||
if err := repo.CreateWorkbenchPresetIfMissing(&presets[index]); err != nil {
|
||
return fmt.Errorf("seed workbench preset %s: %w", presets[index].ID, err)
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func builtinCatalog() ([]model.WorkbenchTool, []model.WorkbenchPreset, error) {
|
||
var entries []builtinToolSeed
|
||
if err := json.Unmarshal(builtinCatalogJSON, &entries); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if len(entries) == 0 {
|
||
return nil, nil, fmt.Errorf("empty catalog")
|
||
}
|
||
tools := make([]model.WorkbenchTool, 0, len(entries))
|
||
presets := make([]model.WorkbenchPreset, 0, len(entries)*3)
|
||
seen := map[string]bool{}
|
||
now := time.Now().UTC()
|
||
for index, entry := range entries {
|
||
if entry.Key == "" || seen[entry.Key] || strings.TrimSpace(entry.Focus) == "" || strings.TrimSpace(entry.InputOne) == "" {
|
||
return nil, nil, fmt.Errorf("invalid or duplicate catalog key %q", entry.Key)
|
||
}
|
||
seen[entry.Key] = true
|
||
capability, guide, err := builtinToolConfig(entry, index)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
tool, err := normalizeTool(ToolInput{
|
||
Kind: entry.Kind, CategoryID: entry.CategoryID, Name: entry.Name, Subtitle: entry.Focus,
|
||
Description: entry.Focus + "。具体画幅、时长与参考素材支持以所选模型能力为准,生成细节需人工验收。",
|
||
SortOrder: 1000 + index*10, Capability: capability, Guide: guide, Enabled: true,
|
||
})
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("tool %s: %w", entry.Key, err)
|
||
}
|
||
tool.ID = builtinCatalogID("tool/" + entry.Key)
|
||
tool.Version, tool.CreatedAt, tool.UpdatedAt = 1, now, now
|
||
tools = append(tools, *tool)
|
||
for _, variant := range builtinPromptVariants(entry) {
|
||
preset, err := normalizePreset(PresetInput{ToolID: tool.ID, Name: variant.name, Summary: variant.summary, PromptTemplate: variant.prompt, SortOrder: variant.order, Enabled: true})
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("preset %s/%s: %w", entry.Key, variant.key, err)
|
||
}
|
||
preset.ID = builtinCatalogID("preset/" + entry.Key + "/" + variant.key)
|
||
preset.Version, preset.CreatedAt, preset.UpdatedAt = 1, now, now
|
||
presets = append(presets, *preset)
|
||
}
|
||
}
|
||
return tools, presets, nil
|
||
}
|
||
|
||
func builtinCatalogID(key string) string {
|
||
return uuid.NewSHA1(uuid.NameSpaceURL, []byte("yingce/workbench/"+key)).String()
|
||
}
|
||
|
||
func builtinToolConfig(entry builtinToolSeed, index int) (json.RawMessage, json.RawMessage, error) {
|
||
contract := InputContract{PromptRequired: true, Slots: []ReferenceSlot{}}
|
||
for i, name := range []string{entry.InputOne, entry.InputTwo} {
|
||
if name == "" || builtinTextInput(name) {
|
||
continue // Script/shot descriptions belong in the prompt, not an image upload slot.
|
||
}
|
||
key, minCount := "reference-main", 1
|
||
if i == 1 {
|
||
key, minCount = "reference-support", 0
|
||
}
|
||
mediaType := KindImage
|
||
if strings.Contains(name, "视频") {
|
||
mediaType = KindVideo
|
||
}
|
||
contract.Slots = append(contract.Slots, ReferenceSlot{Key: key, Name: name, MediaTypes: []string{mediaType}, ResourceKind: "asset", MinCount: minCount, MaxCount: 4, Required: i == 0})
|
||
}
|
||
capability, err := json.Marshal(map[string]any{
|
||
"supportedModes": []string{entry.Kind}, "searchKeywords": []string{entry.Name, entry.CategoryID, entry.Focus},
|
||
"surfaces": []string{"workbench"}, "recommended": index < 12 || entry.Key == "product-hero-motion", "recommendationOrder": index + 1,
|
||
"inputContract": contract,
|
||
})
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
inputs := entry.InputOne
|
||
if entry.InputTwo != "" {
|
||
inputs += ";可补充" + entry.InputTwo
|
||
}
|
||
guide, err := json.Marshal(map[string]any{
|
||
"inputHint": inputs + "。" + entry.Focus,
|
||
"lines": []string{
|
||
"准备:提供" + inputs + "。文字描述填写在提示词中,图像或视频作为参考素材上传。",
|
||
"生成:" + entry.Focus + "。每次只改一个变量;涉及同一商品、角色或场景时,复用相同参考与身份锚点。",
|
||
"验收:检查主体轮廓、颜色、服装、道具、比例、视线、空间轴线与光影连续性;未确认的规格、卖点和文字留给后期核实。",
|
||
},
|
||
})
|
||
return capability, guide, err
|
||
}
|
||
|
||
func builtinTextInput(name string) bool {
|
||
switch name {
|
||
case "剧本/分镜文字", "镜头描述", "剧本片段", "分镜脚本", "角色/音色说明":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
type builtinPromptVariant struct {
|
||
key, name, summary, prompt string
|
||
order int
|
||
}
|
||
|
||
func builtinPromptVariants(entry builtinToolSeed) []builtinPromptVariant {
|
||
if entry.Kind == KindVideo {
|
||
prompt := "单镜头短片," + entry.Focus + "。" + entry.InputOne + "决定主体身份与首帧构图;若有第二参考,只用于对应动作或风格。按“起始状态→一个清晰动作→稳定结束状态”连续演完,镜头运动克制且明确,主体脸、服装、商品结构、道具手别、空间轴线与光源方向全程一致。动作幅度真实,背景不跳变,不新增人物、物件或文字,不发生形变、闪烁或瞬移;结尾停留约1秒,便于剪辑。"
|
||
return []builtinPromptVariant{
|
||
{"standard", "标准单镜头", "一个主体动作 + 一种机位运动 + 稳定尾帧", prompt, 0},
|
||
{"emotion", "情绪强化", "增强表演节奏与光线,不增加动作", prompt + " 情绪方向:从克制开始,在动作完成时出现清楚但不过度的情绪变化;使用单一色温与柔和层次光,避免快速剪切。", 1},
|
||
{"continuity", "干净产品/连续性版", "优先保真与稳定,降低背景运动", prompt + " 保真优先:镜头固定或极慢推进,背景保持静止,主体占画面主要区域,所有标签、服装和道具细节清晰可核对。", 2},
|
||
}
|
||
}
|
||
anchor := "以参考图1为主体外观与身份的唯一依据,"
|
||
if builtinTextInput(entry.InputOne) {
|
||
anchor = "以提供的" + entry.InputOne + "和角色、场景设定为依据,"
|
||
}
|
||
prompt := anchor + entry.Focus + "。保持主体结构、颜色、比例、材质和可见细节稳定;辅助参考仅用于其对应的场景、姿态或风格,不得覆盖主体特征。采用清晰的主体层级、可信透视、自然光影与真实接触关系。画面不生成文字、价格、水印、虚构品牌或未提供的产品信息;输出干净、可用于商业创作的视觉画面。"
|
||
return []builtinPromptVariant{
|
||
{"standard", "标准主视觉", "主体保真,按核心用途生成", prompt, 0},
|
||
{"composition", "构图变化", "锁定主体,只调整机位和留白", prompt + " 构图变体:采用轻微三分法机位,将主体放在画面一侧,另一侧留出干净负空间;主体仍完整可辨,画面层次简洁。", 1},
|
||
{"detail", "细节优先", "适合材质、五金、表情与连续性检查", prompt + " 细节版:镜头略近,优先表现真实纹理、边缘、手部与面部自然结构及接触阴影;不锐化出虚假纹理,不改变主体身份。", 2},
|
||
}
|
||
}
|