From f6b60a385596347555881d7d76985ed2f44cefd2 Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:44:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(workbench):=20=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E5=86=85=E7=BD=AE=E5=B7=A5=E5=85=B7=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 7 + backend/cmd/server/main_test.go | 25 +++ backend/internal/app/workbench_bridge.go | 4 + backend/internal/repository/workbench.go | 9 + backend/internal/workbench/seed.go | 175 +++++++++++++++++++ backend/internal/workbench/seed/catalog.json | 120 +++++++++++++ backend/internal/workbench/seed_test.go | 100 +++++++++++ 7 files changed, 440 insertions(+) create mode 100644 backend/internal/workbench/seed.go create mode 100644 backend/internal/workbench/seed/catalog.json create mode 100644 backend/internal/workbench/seed_test.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 23e6018a9..5058017a6 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -101,6 +101,9 @@ func run(ctx context.Context) error { if err := svc.EnsureBuiltinTools(); err != nil { return err } + if err := ensureBuiltinWorkbenchCatalog(svc); err != nil { + return err + } if summary, err := svc.MigrateLegacyStorage(); err != nil { slog.Warn("storage migration skipped after error", "error", err) } else if summary.Backup != "" { @@ -173,6 +176,10 @@ func run(ctx context.Context) error { return nil } +func ensureBuiltinWorkbenchCatalog(svc *service.Service) error { + return svc.EnsureBuiltinWorkbenchCatalog() +} + func redactCanvasSharePath(path string) string { const prefix = "/api/public/canvas-shares/" if !strings.HasPrefix(path, prefix) { diff --git a/backend/cmd/server/main_test.go b/backend/cmd/server/main_test.go index 9b326148b..f831354e2 100644 --- a/backend/cmd/server/main_test.go +++ b/backend/cmd/server/main_test.go @@ -5,9 +5,34 @@ import ( "testing" "time" + "yingce/backend/internal/database" + "yingce/backend/internal/repository" + "yingce/backend/internal/service" + "github.com/gin-gonic/gin" ) +func TestEnsureBuiltinWorkbenchCatalogStartupWiring(t *testing.T) { + db, err := database.Open(database.Config{Driver: "sqlite", DSN: "file:workbench-startup?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := database.MigrateSchema(db); err != nil { + t.Fatal(err) + } + svc := service.New(repository.New(db), t.TempDir()) + if err := ensureBuiltinWorkbenchCatalog(svc); err != nil { + t.Fatal(err) + } + tools, err := repository.New(db).WorkbenchTools("image", true) + if err != nil { + t.Fatal(err) + } + if len(tools) == 0 { + t.Fatal("startup initializer did not publish image workbench tools") + } +} + func TestAllowedOriginWildcard(t *testing.T) { t.Setenv("CANVAS_CORS_ORIGINS", "*") context, _ := gin.CreateTestContext(httptest.NewRecorder()) diff --git a/backend/internal/app/workbench_bridge.go b/backend/internal/app/workbench_bridge.go index 03b85cd15..e71f9f123 100644 --- a/backend/internal/app/workbench_bridge.go +++ b/backend/internal/app/workbench_bridge.go @@ -64,6 +64,10 @@ func (s *Service) workbenchDomain() *workbench.Service { return workbench.New(s.repo, workbenchAdminGate{svc: s}, workbenchAdminGate{svc: s}) } +func (s *Service) EnsureBuiltinWorkbenchCatalog() error { + return s.workbenchDomain().EnsureBuiltinCatalog() +} + func (s *Service) PublicWorkbenchTools(kind string) ([]WorkbenchToolView, error) { if kind == workbench.KindImage { if err := s.RequireFeature(FeatureImageWorkbench); err != nil { diff --git a/backend/internal/repository/workbench.go b/backend/internal/repository/workbench.go index 826a9c197..d601c2242 100644 --- a/backend/internal/repository/workbench.go +++ b/backend/internal/repository/workbench.go @@ -4,6 +4,7 @@ import ( "yingce/backend/internal/model" "gorm.io/gorm" + "gorm.io/gorm/clause" ) func (r *Repository) WorkbenchTools(kind string, enabledOnly bool) ([]model.WorkbenchTool, error) { @@ -32,6 +33,14 @@ func (r *Repository) CreateWorkbenchTool(item *model.WorkbenchTool) error { } func (r *Repository) SaveWorkbenchTool(item *model.WorkbenchTool) error { return r.db.Save(item).Error } +func (r *Repository) CreateWorkbenchToolIfMissing(item *model.WorkbenchTool) error { + return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(item).Error +} + +func (r *Repository) CreateWorkbenchPresetIfMissing(item *model.WorkbenchPreset) error { + return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(item).Error +} + func (r *Repository) SaveWorkbenchToolWithPresets(item *model.WorkbenchTool, presets *[]model.WorkbenchPreset, create bool) error { return r.db.Transaction(func(tx *gorm.DB) error { var err error diff --git a/backend/internal/workbench/seed.go b/backend/internal/workbench/seed.go new file mode 100644 index 000000000..adba09844 --- /dev/null +++ b/backend/internal/workbench/seed.go @@ -0,0 +1,175 @@ +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}, + } +} diff --git a/backend/internal/workbench/seed/catalog.json b/backend/internal/workbench/seed/catalog.json new file mode 100644 index 000000000..9e0fd9ebe --- /dev/null +++ b/backend/internal/workbench/seed/catalog.json @@ -0,0 +1,120 @@ +[ + {"key": "product-white-hero", "kind": "image", "categoryId": "product", "name": "商品白底主图", "focus": "把商品置于纯白无缝背景中央,完整呈现轮廓、真实颜色、材质和包装细节,使用柔和棚拍光与克制自然投影", "inputOne": "商品原图", "inputTwo": ""}, + {"key": "product-soft-shadow", "kind": "image", "categoryId": "ecommerce", "name": "商品柔影主图", "focus": "清理背景并重建柔和接触阴影,不改变商品外观、比例、标签与配件", "inputOne": "商品原图", "inputTwo": ""}, + {"key": "product-angle-set", "kind": "image", "categoryId": "ecommerce", "name": "商品多角度套图", "focus": "依据同一商品参考生成正面、侧面、背面和斜侧角度,结构、颜色、纹理及标识保持一致", "inputOne": "商品原图", "inputTwo": "细节参考图"}, + {"key": "product-macro-detail", "kind": "image", "categoryId": "ecommerce", "name": "商品细节微距", "focus": "突出材质纹理、做工、接口或关键部位的微距商业摄影,保留真实边缘与比例", "inputOne": "商品原图", "inputTwo": "细节参考图"}, + {"key": "fashion-on-model", "kind": "image", "categoryId": "ecommerce", "name": "服装平铺上身", "focus": "将平铺服装自然穿到模特身上,衣型、印花、缝线、纽扣和颜色逐项忠实保留", "inputOne": "服装平铺图", "inputTwo": "模特姿势参考"}, + {"key": "fashion-lookbook", "kind": "image", "categoryId": "ecommerce", "name": "服装上身套图", "focus": "为同一套服装生成正面、侧面、背面和行走姿态,保持模特身份、衣着与摄影光线一致", "inputOne": "服装图", "inputTwo": "模特参考图"}, + {"key": "fashion-colorway", "kind": "image", "categoryId": "ecommerce", "name": "服装颜色变体", "focus": "只替换服装主色或指定配色,锁定版型、面料纹理、模特、姿势、背景和裁切", "inputOne": "服装原图", "inputTwo": ""}, + {"key": "shoe-on-model", "kind": "image", "categoryId": "ecommerce", "name": "鞋履上脚图", "focus": "将鞋款自然穿在模特脚上,鞋型、鞋底纹路、鞋带、配色与左右脚细节保持一致", "inputOne": "鞋商品图", "inputTwo": "模特姿势参考"}, + {"key": "bag-scale-lifestyle", "kind": "image", "categoryId": "ecommerce", "name": "包袋手持比例场景", "focus": "展示包袋真实尺寸与肩背/手提关系,保留包型、五金、缝线和品牌细节", "inputOne": "包袋原图", "inputTwo": "模特参考图"}, + {"key": "jewelry-wear", "kind": "image", "categoryId": "ecommerce", "name": "饰品佩戴图", "focus": "把饰品自然佩戴于人物身上,准确保持镶嵌、链节、比例和金属色,不增加宝石", "inputOne": "饰品原图", "inputTwo": "佩戴部位参考"}, + {"key": "skincare-packshot", "kind": "image", "categoryId": "ecommerce", "name": "护肤品棚拍主图", "focus": "为瓶罐构建干净专业的美妆棚拍,标签文字与包装结构必须按参考保留,不臆造功效", "inputOne": "护肤品原图", "inputTwo": ""}, + {"key": "skincare-texture", "kind": "image", "categoryId": "ecommerce", "name": "护肤质地细节图", "focus": "以凝露、乳霜或精华质地为视觉重点,瓶身标签保持清晰且不生成未经证实的成分或功效", "inputOne": "护肤品原图", "inputTwo": "质地参考图"}, + {"key": "cosmetic-swatch", "kind": "image", "categoryId": "ecommerce", "name": "彩妆色号展示", "focus": "展示指定色号在皮肤/色卡上的真实观感,维持颜色准确,不添加未提供的色号或宣传文案", "inputOne": "彩妆商品图", "inputTwo": "色号参考图"}, + {"key": "perfume-still-life", "kind": "image", "categoryId": "ecommerce", "name": "香水氛围静物", "focus": "以玻璃反射、液体色泽和高级布光构建香水静物广告,瓶体与标签保持原样", "inputOne": "香水瓶原图", "inputTwo": "品牌氛围参考"}, + {"key": "coffee-packaging", "kind": "image", "categoryId": "ecommerce", "name": "咖啡包装场景图", "focus": "表现咖啡包装、豆粒和冲煮情境,袋型、标签、净含量与印刷位置严格匹配原图", "inputOne": "咖啡商品图", "inputTwo": "场景参考图"}, + {"key": "food-menu-hero", "kind": "image", "categoryId": "ecommerce", "name": "食品菜单主图", "focus": "将菜品拍成自然可食用的菜单主视觉,准确呈现份量、配料和器皿,不凭空增加食材", "inputOne": "食品参考图", "inputTwo": "器皿参考图"}, + {"key": "drink-cold-scene", "kind": "image", "categoryId": "ecommerce", "name": "饮料冰感场景图", "focus": "呈现杯身、饮料颜色、冰块与冷凝水的清爽广告静物,不改变包装或液体实际颜色", "inputOne": "饮料商品图", "inputTwo": "背景参考图"}, + {"key": "furniture-room", "kind": "image", "categoryId": "ecommerce", "name": "家具空间主图", "focus": "将家具自然放入尺度可信的居家空间,家具结构、尺寸比例、颜色和面料保持参考一致", "inputOne": "家具商品图", "inputTwo": "室内空间参考"}, + {"key": "home-decor-vignette", "kind": "image", "categoryId": "ecommerce", "name": "家居软装搭配图", "focus": "用合理比例与克制道具呈现家居用品使用情境,商品是主角,避免遮挡商品关键结构", "inputOne": "家居商品图", "inputTwo": "空间风格参考"}, + {"key": "electronics-packshot", "kind": "image", "categoryId": "ecommerce", "name": "数码商品主图", "focus": "突出设备机身、接口、按键和真实材质,屏幕保持干净且不伪造界面或品牌文字", "inputOne": "数码商品图", "inputTwo": ""}, + {"key": "appliance-in-use", "kind": "image", "categoryId": "ecommerce", "name": "小家电使用场景", "focus": "展示小家电真实使用姿态与合理空间关系,机身比例、按钮和线缆结构保持准确", "inputOne": "家电商品图", "inputTwo": "使用场景参考"}, + {"key": "packaging-lineup", "kind": "image", "categoryId": "ecommerce", "name": "包装套系陈列", "focus": "将同一品牌的不同包装有序陈列,统一光线、机位与比例,逐一保留每个 SKU 标签", "inputOne": "多款包装图", "inputTwo": ""}, + {"key": "listing-benefit-layout", "kind": "image", "categoryId": "ecommerce", "name": "电商详情卖点版式", "focus": "围绕已确认卖点规划主体、局部特写和留白区域,文字区只留干净空白供后期排版,不生成乱码", "inputOne": "商品图", "inputTwo": "品牌风格参考"}, + {"key": "vertical-product-page", "kind": "image", "categoryId": "ecommerce", "name": "竖版商品详情视觉", "focus": "按移动端竖屏阅读顺序安排主视觉、材质细节和场景图,商品外观一致且保留安全留白", "inputOne": "商品图", "inputTwo": "详情页参考"}, + {"key": "campaign-key-visual", "kind": "image", "categoryId": "ecommerce", "name": "电商活动主视觉", "focus": "为活动海报生成商品主视觉与可编辑留白,使用指定色彩层级,不生成促销数字、价格或伪文字", "inputOne": "商品图", "inputTwo": "品牌色参考"}, + {"key": "ugc-cover-product", "kind": "image", "categoryId": "short-video", "name": "商品 UGC 封面图", "focus": "构建可信的手持开箱/使用封面,商品清晰可辨,人物动作自然,预留后期标题区", "inputOne": "商品图", "inputTwo": "人物/手势参考"}, + {"key": "short-ad-cover", "kind": "image", "categoryId": "short-video", "name": "竖屏短广告封面", "focus": "围绕一个明确商品卖点做强主体竖版封面,主体位置适配平台裁切,留白给后期标题", "inputOne": "商品图", "inputTwo": "视觉风格参考"}, + {"key": "campaign-ab-variants", "kind": "image", "categoryId": "ecommerce", "name": "商品广告 A/B 变体", "focus": "在保持 SKU 和品牌不变的前提下,生成两种不同构图/背景方向用于创意测试,不虚构产品利益点", "inputOne": "商品图", "inputTwo": "品牌参考图"}, + {"key": "product-background-replace", "kind": "image", "categoryId": "product", "name": "商品换背景", "focus": "只替换环境背景与地面光影,商品形状、颜色、标签、透视及附件像素级忠实", "inputOne": "商品原图", "inputTwo": "目标背景参考"}, + {"key": "fabric-detail-board", "kind": "image", "categoryId": "ecommerce", "name": "面料与工艺细节板", "focus": "用近景展示织法、面料厚度、接缝与纽扣,细节必须来自参考图,不补造隐藏工艺", "inputOne": "服装原图", "inputTwo": "工艺细节参考"}, + {"key": "beauty-routine-scene", "kind": "image", "categoryId": "ecommerce", "name": "美妆日常场景图", "focus": "把产品置入简洁日常护理台面或浴室场景,包装标签可辨,避免暗示医疗或未证实功效", "inputOne": "美妆商品图", "inputTwo": "场景参考"}, + {"key": "electronics-lifestyle", "kind": "image", "categoryId": "ecommerce", "name": "数码产品生活方式图", "focus": "将设备融入真实办公/通勤场景,机身与屏幕不变形,显示内容保持抽象或空白", "inputOne": "数码商品图", "inputTwo": "环境参考"}, + {"key": "product-size-context", "kind": "image", "categoryId": "ecommerce", "name": "商品尺寸参照图", "focus": "通过常见物体或手持关系帮助理解商品尺寸,比例明确,不虚构标注数值", "inputOne": "商品图", "inputTwo": "尺寸参照物"}, + {"key": "product-collection-grid", "kind": "image", "categoryId": "ecommerce", "name": "商品系列陈列图", "focus": "为同一系列商品生成统一背景、间距和阴影的整齐组合陈列,避免产品互相遮挡", "inputOne": "系列商品图", "inputTwo": ""}, + {"key": "beauty-flatlay", "kind": "image", "categoryId": "ecommerce", "name": "美妆平铺组合图", "focus": "俯拍排布产品、包装和少量真实配件,保持每件商品标签方向与外观清楚", "inputOne": "美妆商品图", "inputTwo": "配件参考"}, + {"key": "food-ingredient-story", "kind": "image", "categoryId": "ecommerce", "name": "食品原料故事图", "focus": "以产品和已知原料构建清晰静物叙事,不添加配方中未确认的成分或健康承诺", "inputOne": "食品商品图", "inputTwo": "原料参考图"}, + {"key": "drama-character-bible", "kind": "image", "categoryId": "drama-character", "name": "短剧角色定妆立绘", "focus": "建立可复用的角色外貌锚点:年龄感、脸型、发型、肤色、体态与服装层次固定", "inputOne": "角色参考图", "inputTwo": "服装参考图"}, + {"key": "drama-turnaround", "kind": "image", "categoryId": "drama-character", "name": "短剧角色四视图", "focus": "同一角色生成正面、侧面、背面和四分之三角度,发型、服装、体型和配件一致", "inputOne": "角色参考图", "inputTwo": "服装/道具参考"}, + {"key": "drama-expression-sheet", "kind": "image", "categoryId": "drama-character", "name": "短剧角色表情表", "focus": "为同一角色生成克制的喜怒惊疑等表情变化,保持脸部身份、发型、年龄与服装不漂移", "inputOne": "角色定妆图", "inputTwo": ""}, + {"key": "drama-costume-variants", "kind": "image", "categoryId": "drama-character", "name": "短剧角色服装变体", "focus": "为角色设计指定剧情阶段的服装方案,只改变服装与配件,保留角色身份和时代设定", "inputOne": "角色定妆图", "inputTwo": "服装方向参考"}, + {"key": "drama-prop-turnaround", "kind": "image", "categoryId": "drama-character", "name": "短剧关键道具三视图", "focus": "生成关键道具正侧背与细节参考,锁定形状、材质、磨损、握持方式和剧情状态", "inputOne": "道具参考图", "inputTwo": ""}, + {"key": "drama-location-master", "kind": "image", "categoryId": "drama-scene", "name": "短剧场景空间母版", "focus": "建立场景空间母版,明确入口、窗口、主要家具、主光方向和可表演区域", "inputOne": "场景参考图", "inputTwo": "美术风格参考"}, + {"key": "drama-location-coverage", "kind": "image", "categoryId": "drama-scene", "name": "短剧场景多角度", "focus": "从同一空间生成全景、反打和侧面角度,门窗位置、地标、光线方向与空间轴线保持一致", "inputOne": "场景母版", "inputTwo": "镜头角度参考"}, + {"key": "drama-time-variants", "kind": "image", "categoryId": "drama-scene", "name": "短剧场景昼夜变体", "focus": "同一场景切换清晨/夜晚/雨天等指定时段,只改变时间氛围,空间布局和地标固定", "inputOne": "场景母版", "inputTwo": "时间气氛参考"}, + {"key": "drama-storyboard-grid", "kind": "image", "categoryId": "storyboard", "name": "短剧九宫格分镜", "focus": "把一段已确认剧情拆为九格关键帧,每格单一动作、机位和情绪节点,人物造型连续", "inputOne": "剧本/分镜文字", "inputTwo": "角色与场景参考"}, + {"key": "drama-keyframe", "kind": "image", "categoryId": "storyboard", "name": "短剧镜头关键帧", "focus": "生成单镜头首帧构图,明确主体位置、视线、手部、持物、景别和画面左右关系", "inputOne": "镜头描述", "inputTwo": "角色/场景参考"}, + {"key": "drama-blocking-two-shot", "kind": "image", "categoryId": "storyboard", "name": "短剧双人调度图", "focus": "呈现双人站位和视线方向,明确画面左/右、前景/后景与摄影轴线,方便连续拍摄", "inputOne": "角色参考图", "inputTwo": "场景母版"}, + {"key": "drama-over-shoulder", "kind": "image", "categoryId": "storyboard", "name": "短剧过肩反打关键帧", "focus": "生成对话过肩镜头,保持视线轴线、人物相对位置、焦平面和背景地标一致", "inputOne": "角色参考图", "inputTwo": "上一镜关键帧"}, + {"key": "drama-continuity-repair", "kind": "image", "categoryId": "storyboard", "name": "短剧分镜连续性修复", "focus": "依据上一镜结束状态修正本镜起始状态,锁定服装、伤痕、道具手别、视线和空间方向", "inputOne": "上一镜帧", "inputTwo": "当前镜头草图"}, + {"key": "drama-identity-lock", "kind": "image", "categoryId": "drama-character", "name": "短剧角色一致性参考", "focus": "把多张角色参考整合为一份稳定外貌锚点,明确必须保持的脸型、发型、服装和年龄感", "inputOne": "角色多角度图", "inputTwo": ""}, + {"key": "drama-wardrobe-ledger", "kind": "image", "categoryId": "drama-character", "name": "短剧服装连续性图", "focus": "按剧情阶段展示服装状态与可变项,固定角色身份、主色、发型和配件", "inputOne": "角色图", "inputTwo": "服装状态参考"}, + {"key": "drama-weather-continuity", "kind": "image", "categoryId": "drama-scene", "name": "短剧天气光线连续性", "focus": "为同一场景制作指定天气与主光方向的镜头参考,雨量、地面湿度和光源位置连续", "inputOne": "场景参考图", "inputTwo": "天气氛围参考"}, + {"key": "drama-period-costume", "kind": "image", "categoryId": "drama-character", "name": "年代短剧角色造型", "focus": "按明确年代与身份生成角色造型参考,服装版型、发型、配饰和妆容避免混搭时代", "inputOne": "角色参考图", "inputTwo": "年代服饰参考"}, + {"key": "drama-modern-suspense-set", "kind": "image", "categoryId": "drama-scene", "name": "现代悬疑短剧场景", "focus": "设计可拍摄的悬疑室内场景,明确出口、遮挡物、光源和关键线索位置", "inputOne": "场景参考图", "inputTwo": "线索道具图"}, + {"key": "drama-romance-poster", "kind": "image", "categoryId": "short-video", "name": "短剧情感封面海报", "focus": "生成适合竖屏分发的情感短剧封面构图,人物关系清楚,标题区域留白,不生成文字", "inputOne": "角色定妆图", "inputTwo": "气氛参考图"}, + {"key": "drama-reveal-closeup", "kind": "image", "categoryId": "storyboard", "name": "短剧反转揭示特写", "focus": "为剧情揭示瞬间构建可读的面部特写,控制视线、微表情、景深和关键道具露出", "inputOne": "角色参考图", "inputTwo": "线索道具图"}, + {"key": "script-to-storyboard", "kind": "image", "categoryId": "storyboard", "name": "短剧剧本转分镜画面", "focus": "按已确认剧本逐镜生成可制作的关键帧,包含景别、动作起止、视线与道具状态", "inputOne": "剧本片段", "inputTwo": "角色/场景资产"}, + {"key": "comic-drama-panels", "kind": "image", "categoryId": "storyboard", "name": "漫剧分格画面", "focus": "将单个剧情节拍拆为连贯漫画格,统一人物造型、阅读顺序和光色,不在画面内生成气泡字", "inputOne": "角色设定图", "inputTwo": "分镜脚本"}, + {"key": "drama-set-extension", "kind": "image", "categoryId": "drama-scene", "name": "短剧场景横向扩展", "focus": "扩展原场景画布供竖屏/横屏裁切,建筑结构、光源方向和地标连续,不增加剧情信息", "inputOne": "场景母版", "inputTwo": ""}, + {"key": "drama-clue-insert", "kind": "image", "categoryId": "storyboard", "name": "短剧线索道具插入帧", "focus": "生成手部与线索道具特写,明确左右手、握持方式、道具朝向与景深焦点", "inputOne": "角色参考图", "inputTwo": "道具参考图"}, + {"key": "drama-ensemble-lineup", "kind": "image", "categoryId": "drama-character", "name": "短剧群像关系站位", "focus": "按人物关系生成群像定妆站位,身高比例、服装身份差异和画面层次清晰", "inputOne": "角色参考图", "inputTwo": "场景参考图"}, + {"key": "drama-power-pose", "kind": "image", "categoryId": "drama-character", "name": "短剧人物气场立绘", "focus": "用体态、视线、服装輪廓和机位体现角色权力关系,避免改变已锁定的面部身份", "inputOne": "角色定妆图", "inputTwo": "姿态参考图"}, + {"key": "drama-opening-cover", "kind": "image", "categoryId": "short-video", "name": "短剧开篇竖屏封面", "focus": "突出主角与本集冲突信息的视觉焦点,竖屏安全区留白,不生成标题和平台标识", "inputOne": "角色定妆图", "inputTwo": "本集情绪参考"}, + {"key": "drama-location-redress", "kind": "image", "categoryId": "drama-scene", "name": "短剧场景陈设变体", "focus": "在不改变空间结构的前提下更换指定年代/剧情阶段的陈设,固定门窗、轴线和摄影地标", "inputOne": "场景母版", "inputTwo": "陈设参考"}, + {"key": "drama-color-script", "kind": "image", "categoryId": "enhancement", "name": "短剧色彩风格帧", "focus": "为一集建立统一色彩与光影参考,肤色自然、主色克制,并保留各场景可辨识度", "inputOne": "已有关键帧", "inputTwo": "色彩方向参考"}, + {"key": "portrait-realism", "kind": "image", "categoryId": "people", "name": "人物写真真实感增强", "focus": "修复人像光影、发丝与皮肤细节,保留原人物身份、年龄、肤色和自然纹理", "inputOne": "人像原图", "inputTwo": ""}, + {"key": "background-replace", "kind": "image", "categoryId": "scene", "name": "人物/商品换背景", "focus": "只替换背景并匹配透视、接触阴影和环境光,主体结构与细节保持不变", "inputOne": "主体原图", "inputTwo": "目标背景图"}, + {"key": "image-restoration", "kind": "image", "categoryId": "enhancement", "name": "图片修复与清晰化", "focus": "提升清晰度、去除压缩噪点并恢复边缘,不重画文字、商标、五官或商品结构", "inputOne": "待修复图片", "inputTwo": ""}, + {"key": "style-transfer-reference", "kind": "image", "categoryId": "enhancement", "name": "统一视觉风格参考", "focus": "把参考图的色彩、笔触、光线和质感迁移到目标画面,构图和主体身份保持", "inputOne": "目标图", "inputTwo": "风格参考图"}, + {"key": "product-hero-motion", "kind": "video", "categoryId": "ecommerce", "name": "商品广告英雄出场", "focus": "单镜头缓慢推进至商品,商品保持静止且标签清晰,背景光线轻微流动,结尾停留完整主视觉", "inputOne": "商品首帧", "inputTwo": "运动参考视频"}, + {"key": "product-orbit", "kind": "video", "categoryId": "ecommerce", "name": "商品缓慢环绕展示", "focus": "镜头围绕静置商品平稳移动约四分之一圈,维持商品结构、透视、包装文字和背景连续", "inputOne": "商品首帧", "inputTwo": "商品多角度参考"}, + {"key": "product-macro-push", "kind": "video", "categoryId": "ecommerce", "name": "商品细节微距推进", "focus": "镜头缓慢推近指定材质/接口并保持焦点稳定,商品不变形,微距结束时留出清晰细节", "inputOne": "商品细节首帧", "inputTwo": "细节参考图"}, + {"key": "fashion-fabric-motion", "kind": "video", "categoryId": "ecommerce", "name": "服装面料动态展示", "focus": "模特保持自然站姿,轻微转身带动衣料产生真实褶皱,衣型、印花和颜色锁定", "inputOne": "服装首帧", "inputTwo": "模特参考图"}, + {"key": "fashion-model-turn", "kind": "video", "categoryId": "ecommerce", "name": "服装模特转身展示", "focus": "模特从正面缓慢转向侧面并回望镜头,单一连续动作,衣着与身份保持一致", "inputOne": "模特首帧", "inputTwo": "服装参考图"}, + {"key": "shoe-walk-demo", "kind": "video", "categoryId": "ecommerce", "name": "鞋履步态展示", "focus": "脚步自然向前两步,镜头低位跟拍鞋面和鞋底,鞋款结构、左右脚及步态连续", "inputOne": "鞋履首帧", "inputTwo": "鞋款参考图"}, + {"key": "bag-lifestyle-walk", "kind": "video", "categoryId": "ecommerce", "name": "包袋通勤动态展示", "focus": "人物自然行走并轻微摆动手提包,镜头平稳跟随,包型、五金与手部接触保持稳定", "inputOne": "包袋首帧", "inputTwo": "模特参考图"}, + {"key": "jewelry-light-motion", "kind": "video", "categoryId": "ecommerce", "name": "珠宝微光动态展示", "focus": "镜头缓慢侧移,珠宝反射光轻柔掠过,宝石数量、形状、镶口与佩戴位置不变", "inputOne": "饰品首帧", "inputTwo": "饰品细节图"}, + {"key": "serum-drop-demo", "kind": "video", "categoryId": "ecommerce", "name": "精华滴管产品镜头", "focus": "滴管缓慢抬起并落下一滴透明液体,瓶身和标签固定,动作简洁、液体比例真实", "inputOne": "产品首帧", "inputTwo": "包装参考图"}, + {"key": "skincare-routine", "kind": "video", "categoryId": "ecommerce", "name": "护肤品使用动作演示", "focus": "手部将产品轻放于台面并做一次简单取用动作,包装保持准确,不暗示治疗效果", "inputOne": "产品首帧", "inputTwo": "使用步骤参考"}, + {"key": "perfume-mist", "kind": "video", "categoryId": "ecommerce", "name": "香水喷雾广告镜头", "focus": "手指轻按喷头,细雾在侧逆光中短暂显现,瓶体、标签和液体颜色不改变", "inputOne": "香水首帧", "inputTwo": "品牌光线参考"}, + {"key": "coffee-pour", "kind": "video", "categoryId": "ecommerce", "name": "咖啡冲煮动态镜头", "focus": "热水稳定注入咖啡滤杯,蒸汽轻微升起,镜头缓慢推进,器具比例和咖啡颜色真实", "inputOne": "咖啡首帧", "inputTwo": "器具参考图"}, + {"key": "food-steam-closeup", "kind": "video", "categoryId": "ecommerce", "name": "食品热气特写", "focus": "镜头轻推近刚出锅的菜品,蒸汽自然上升,配料与摆盘保持原样,不突然增减食物", "inputOne": "菜品首帧", "inputTwo": "菜单参考图"}, + {"key": "unboxing-sequence", "kind": "video", "categoryId": "ecommerce", "name": "商品开箱动作", "focus": "双手平稳打开包装并露出商品,包装印刷不变,动作按顺序完成且不跳帧", "inputOne": "包装首帧", "inputTwo": "商品/包装参考图"}, + {"key": "appliance-action", "kind": "video", "categoryId": "ecommerce", "name": "小家电功能动作展示", "focus": "设备完成一个明确的开关/运行动作,按键和结构固定,镜头记录状态变化,不生成界面文字", "inputOne": "家电首帧", "inputTwo": "产品细节图"}, + {"key": "furniture-room-reveal", "kind": "video", "categoryId": "ecommerce", "name": "家具空间揭示镜头", "focus": "镜头从局部平稳后退显露完整房间与家具,空间尺寸可信,家具结构和材质不变", "inputOne": "家具首帧", "inputTwo": "空间参考图"}, + {"key": "electronics-detail-demo", "kind": "video", "categoryId": "ecommerce", "name": "数码产品环绕特写", "focus": "镜头从机身侧边移动到接口特写,屏幕保持抽象纯色,按钮数量与机身轮廓固定", "inputOne": "数码首帧", "inputTwo": "接口细节图"}, + {"key": "vertical-product-hook", "kind": "video", "categoryId": "short-video", "name": "竖屏商品三秒钩子", "focus": "开场立即看到商品与一个清晰动作,镜头快速但平稳推进,商品全程在竖屏安全区内", "inputOne": "商品首帧", "inputTwo": "广告节奏参考"}, + {"key": "handheld-product-demo", "kind": "video", "categoryId": "ecommerce", "name": "手持商品使用演示", "focus": "手部拿起商品并展示一个真实使用动作,手指数目自然,包装文字和商品外观锁定", "inputOne": "商品首帧", "inputTwo": "手部/动作参考"}, + {"key": "product-end-card-motion", "kind": "video", "categoryId": "ecommerce", "name": "商品广告收尾停留", "focus": "镜头轻微推近后稳定停留在完整包装主视觉,背景保持干净,结尾留白供后期加文案", "inputOne": "商品首帧", "inputTwo": "品牌背景参考"}, + {"key": "spotlight-packshot", "kind": "video", "categoryId": "ecommerce", "name": "商品聚光棚拍动态", "focus": "背景保持静止,柔和聚光从侧面扫过商品表面并结束于均匀主光,商品不旋转变形", "inputOne": "商品首帧", "inputTwo": ""}, + {"key": "product-compare-motion", "kind": "video", "categoryId": "ecommerce", "name": "商品前后状态对照镜头", "focus": "按用户提供的两种真实状态做平稳左右对照,不添加性能结论、数值或未经证实的效果", "inputOne": "状态A首帧", "inputTwo": "状态B参考图"}, + {"key": "product-360-still", "kind": "video", "categoryId": "ecommerce", "name": "单品静帧转展示视频", "focus": "以商品原图为首帧,只做缓慢镜头环绕/推拉与灯光变化,严禁重绘商品细节", "inputOne": "商品首帧", "inputTwo": "商品参考图"}, + {"key": "drama-character-entrance", "kind": "video", "categoryId": "drama-character", "name": "短剧主角入场镜头", "focus": "角色从画面边缘走入并在指定地标前停下,服装、发型、持物和画面方向连续", "inputOne": "镜头首帧", "inputTwo": "角色定妆图"}, + {"key": "drama-single-dialogue", "kind": "video", "categoryId": "storyboard", "name": "短剧单人对白镜头", "focus": "角色说出一小句台词并完成一个微表情,视线固定在画外对手方向,镜头缓慢推近", "inputOne": "镜头首帧", "inputTwo": "角色参考图"}, + {"key": "drama-reaction-closeup", "kind": "video", "categoryId": "storyboard", "name": "短剧反应特写", "focus": "角色听到信息后先保持静止再出现克制的表情变化,目光落点清晰,机位稳定", "inputOne": "镜头首帧", "inputTwo": "角色定妆图"}, + {"key": "drama-two-person-dialogue", "kind": "video", "categoryId": "storyboard", "name": "短剧双人对话调度", "focus": "两人按首帧左右位置进行一轮简短交流,只移动说话者的视线与轻微手势,摄影轴线不变", "inputOne": "双人首帧", "inputTwo": "角色参考图"}, + {"key": "drama-ots-reverse", "kind": "video", "categoryId": "storyboard", "name": "短剧过肩反打镜头", "focus": "从肩后轻微移焦至对手反应,保持视线方向、前后景和人物站位与参考镜头一致", "inputOne": "过肩首帧", "inputTwo": "反打构图参考"}, + {"key": "drama-walk-up-turn", "kind": "video", "categoryId": "drama-character", "name": "短剧人物走近回身", "focus": "角色向镜头走两步并在标记处回身,动作单一连贯,服饰、手中道具与背景地标锁定", "inputOne": "角色首帧", "inputTwo": "场景母版"}, + {"key": "drama-clue-reveal", "kind": "video", "categoryId": "storyboard", "name": "短剧线索揭示镜头", "focus": "镜头由人物视线平移至关键道具并停留,线索保持可读,手部和道具位置不跳变", "inputOne": "线索首帧", "inputTwo": "道具参考图"}, + {"key": "drama-hand-insert", "kind": "video", "categoryId": "storyboard", "name": "短剧手部道具特写", "focus": "手从画面边缘拿起指定物件并翻转一次,左右手和握持关系正确,镜头保持近景", "inputOne": "手部首帧", "inputTwo": "道具参考图"}, + {"key": "drama-rain-window", "kind": "video", "categoryId": "drama-scene", "name": "短剧雨窗氛围镜头", "focus": "窗外雨滴缓慢滑落,室内角色仅有轻微呼吸动作,灯光方向、窗框和空间布局保持不变", "inputOne": "场景首帧", "inputTwo": "角色参考图"}, + {"key": "drama-night-establishing", "kind": "video", "categoryId": "drama-scene", "name": "短剧夜景建立镜头", "focus": "镜头沿街道缓慢前移建立地点,招牌不生成可读文字,地标、天气和光源位置稳定", "inputOne": "场景首帧", "inputTwo": "地点参考图"}, + {"key": "drama-corridor-follow", "kind": "video", "categoryId": "drama-scene", "name": "短剧走廊跟拍镜头", "focus": "镜头从角色身后平稳跟随其沿走廊前行,门窗顺序与空间纵深连续,不穿越墙体", "inputOne": "走廊首帧", "inputTwo": "角色参考图"}, + {"key": "drama-look-back", "kind": "video", "categoryId": "storyboard", "name": "短剧悬念回望镜头", "focus": "角色听见画外声后停步并缓慢回头,表情克制,机位与视线落点清晰", "inputOne": "镜头首帧", "inputTwo": "角色定妆图"}, + {"key": "drama-flashback-transition", "kind": "video", "categoryId": "storyboard", "name": "短剧回忆转场镜头", "focus": "以一次柔和曝光/焦点变化进入回忆画面,不改变角色身份,转场前后动作衔接清楚", "inputOne": "当前镜头首帧", "inputTwo": "回忆场景参考"}, + {"key": "drama-romance-meet", "kind": "video", "categoryId": "drama-character", "name": "短剧情感相遇镜头", "focus": "两位角色在同一空间短暂停步并交换视线,动作自然克制,人物位置和背景关系稳定", "inputOne": "双人首帧", "inputTwo": "角色参考图"}, + {"key": "drama-confrontation", "kind": "video", "categoryId": "storyboard", "name": "短剧对峙调度镜头", "focus": "两人保持安全距离进行克制对峙,仅有一步靠近或手势变化,摄影轴线与地标不变", "inputOne": "双人首帧", "inputTwo": "场景母版"}, + {"key": "drama-fantasy-reveal", "kind": "video", "categoryId": "drama-character", "name": "短剧奇幻角色揭示", "focus": "镜头缓慢揭示角色轮廓与环境光效,角色面部、服饰和身体结构不改变,动作单一", "inputOne": "角色首帧", "inputTwo": "风格参考图"}, + {"key": "drama-keyframe-animation", "kind": "video", "categoryId": "storyboard", "name": "短剧关键帧图生视频", "focus": "将关键帧里的一个明确动作扩展成短镜头,保持角色、服装、道具、场景和机位连续", "inputOne": "冻结关键帧", "inputTwo": "角色/场景参考"}, + {"key": "drama-first-last-frame", "kind": "video", "categoryId": "storyboard", "name": "短剧首尾帧过渡", "focus": "严格从指定首帧移动到指定尾帧,只补足两帧间合理动作,不新增人物或改变道具状态", "inputOne": "首帧", "inputTwo": "尾帧"}, + {"key": "drama-vertical-hook", "kind": "video", "categoryId": "short-video", "name": "竖屏短剧开场钩子", "focus": "用一个清晰动作和快速建立的情绪焦点抓住前三秒,主体处在竖屏安全区,背景信息简洁", "inputOne": "开场首帧", "inputTwo": "角色参考图"}, + {"key": "drama-lipsync-line", "kind": "video", "categoryId": "storyboard", "name": "短剧单句口型表演", "focus": "角色对镜头外对象说出指定短句,嘴部动作自然,台词不扩写,表情与角色设定一致", "inputOne": "对白首帧", "inputTwo": "角色/音色说明"}, + {"key": "drama-ending-hold", "kind": "video", "categoryId": "drama-scene", "name": "短剧结尾悬念停帧", "focus": "角色完成一个克制动作后停留在悬念构图,镜头稳定保持结尾画面,不自行揭示答案", "inputOne": "结尾首帧", "inputTwo": "角色/道具参考"}, + {"key": "image-to-video-motion", "kind": "video", "categoryId": "short-video", "name": "参考图轻动作动画", "focus": "保留参考图构图与主体身份,仅添加呼吸、微风或轻微镜头运动等一种低幅度动作", "inputOne": "参考首帧", "inputTwo": "运动參考视频"}, + {"key": "flower-motion-study", "kind": "video", "categoryId": "scene", "name": "花植轻风运镜", "focus": "以花朵近景为主体,微风带动花瓣和叶片轻轻摆动,镜头缓慢侧移并保持柔和自然光", "inputOne": "花植首帧", "inputTwo": "运动参考视频"}, + {"key": "environment-reveal", "kind": "video", "categoryId": "scene", "name": "环境空间揭示视频", "focus": "从局部平稳移动到完整空间全景,建筑结构、地标、透视和光源方向保持连贯", "inputOne": "环境首帧", "inputTwo": "空间参考图"}, + {"key": "camera-motion-study", "kind": "video", "categoryId": "short-video", "name": "镜头运动测试", "focus": "对同一静态主体测试一种明确机位运动,主体不变形,背景透视连续,不叠加多余动作", "inputOne": "测试首帧", "inputTwo": "机位参考图"}, + {"key": "abstract-transition", "kind": "video", "categoryId": "short-video", "name": "短片氛围转场", "focus": "在两个给定画面之间用一种克制的光线/焦点转场连接,保留主体轮廓与色彩关系", "inputOne": "转场首帧", "inputTwo": "目标帧"} +] diff --git a/backend/internal/workbench/seed_test.go b/backend/internal/workbench/seed_test.go new file mode 100644 index 000000000..509dfd9ef --- /dev/null +++ b/backend/internal/workbench/seed_test.go @@ -0,0 +1,100 @@ +package workbench + +import ( + "testing" + + "yingce/backend/internal/model" +) + +func TestEnsureBuiltinCatalogPublishesImageAndVideoPresets(t *testing.T) { + svc, db, _ := newWorkbenchTest(t) + if err := svc.EnsureBuiltinCatalog(); err != nil { + t.Fatal(err) + } + for _, kind := range []string{KindImage, KindVideo} { + tools, err := svc.PublicTools(kind) + if err != nil { + t.Fatal(err) + } + if len(tools) == 0 { + t.Fatalf("no builtin %s tools", kind) + } + for _, tool := range tools { + if len(tool.Presets) != 3 { + t.Fatalf("tool %s has %d public presets, want 3", tool.ID, len(tool.Presets)) + } + for _, preset := range tool.Presets { + if preset.ToolID != tool.ID || preset.PromptTemplate == "" || !preset.Enabled { + t.Fatalf("unusable public preset: %#v", preset) + } + } + } + } + var count int64 + if err := db.Model(&model.WorkbenchTool{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count < 2 { + t.Fatalf("tool count=%d", count) + } +} + +func TestEnsureBuiltinCatalogIsRepeatableAndPreservesAdminState(t *testing.T) { + svc, db, _ := newWorkbenchTest(t) + seedTools, seedPresets, err := builtinCatalog() + if err != nil { + t.Fatal(err) + } + editedTool := seedTools[0] + editedTool.Name = "管理员自定义名称" + editedTool.Enabled = false + editedTool.Version = 9 + editedPreset := seedPresets[0] + editedPreset.Name = "管理员自定义预设" + editedPreset.PromptTemplate = "管理员自己的提示词" + editedPreset.Enabled = false + editedPreset.Version = 6 + if err := db.Create(&editedTool).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&editedPreset).Error; err != nil { + t.Fatal(err) + } + if err := svc.EnsureBuiltinCatalog(); err != nil { + t.Fatal(err) + } + var firstTools, firstPresets int64 + if err := db.Model(&model.WorkbenchTool{}).Count(&firstTools).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.WorkbenchPreset{}).Count(&firstPresets).Error; err != nil { + t.Fatal(err) + } + if err := svc.EnsureBuiltinCatalog(); err != nil { + t.Fatal(err) + } + var secondTools, secondPresets int64 + if err := db.Model(&model.WorkbenchTool{}).Count(&secondTools).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.WorkbenchPreset{}).Count(&secondPresets).Error; err != nil { + t.Fatal(err) + } + if firstTools != secondTools || firstPresets != secondPresets { + t.Fatalf("repeat seed changed counts: tools %d/%d presets %d/%d", firstTools, secondTools, firstPresets, secondPresets) + } + var gotTool model.WorkbenchTool + if err := db.First(&gotTool, "id = ?", editedTool.ID).Error; err != nil { + t.Fatal(err) + } + if gotTool.Name != editedTool.Name || gotTool.Enabled != editedTool.Enabled || gotTool.Version != editedTool.Version { + t.Fatalf("admin tool changed: %#v", gotTool) + } + var gotPreset model.WorkbenchPreset + if err := db.First(&gotPreset, "id = ?", editedPreset.ID).Error; err != nil { + t.Fatal(err) + } + if gotPreset.Name != editedPreset.Name || gotPreset.PromptTemplate != editedPreset.PromptTemplate || gotPreset.Enabled != editedPreset.Enabled || gotPreset.Version != editedPreset.Version { + t.Fatalf("admin preset changed: %#v", gotPreset) + } +}