museav-cli 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -80,6 +80,32 @@ export interface GenerateOptions {
80
80
  * · 只派给声明了该能力的上游;一家都没有时返回 400 说明原因,**不会静默出白底图**
81
81
  */
82
82
  background?: 'transparent' | 'opaque'
83
+ /** 项目归档:生成结果挂到该工作区(中台仅账户身份收,租户身份忽略) */
84
+ workspace_id?: string
85
+ }
86
+
87
+ /** 工作区(项目):平台账户下的项目容器,素材库挂在它上面(GET/POST /api/workspaces) */
88
+ export interface Workspace {
89
+ id: string
90
+ name: string
91
+ brand?: string | null
92
+ description?: string | null
93
+ /** 该项目累计提交 / 完成的生成数(列表接口附带的统计) */
94
+ gen_total?: number
95
+ gen_done?: number
96
+ created_at?: string
97
+ }
98
+
99
+ /** 工作区素材(GET/POST /api/workspace-assets):项目素材库的一条记录 */
100
+ export interface WorkspaceAsset {
101
+ id: string
102
+ workspace_id: string
103
+ media_type: 'image' | 'video' | 'audio'
104
+ cdn_url: string
105
+ name: string | null
106
+ tags: string[]
107
+ size_bytes?: number | null
108
+ created_at?: string
83
109
  }
84
110
 
85
111
  /** 图片/文字模板清单项(GET /api/templates,template_type=image|article) */
@@ -101,7 +127,12 @@ export interface TemplateOption {
101
127
  model: string
102
128
  prompt_template: string
103
129
  ref_slots?: string[]
130
+ /** 表单字段声明——现行契约放 config 顶层(服务端 validateConfig 读这里) */
131
+ fields?: Array<{ key: string; label: string }>
132
+ /** 旧存法:fields 曾在 params_json 里,老模板还这么存,读时两种都要兜 */
104
133
  params_json?: { fields?: Array<{ key: string; label: string; placeholder?: string }> }
134
+ duration?: number
135
+ aspect_ratio?: string
105
136
  is_default?: boolean
106
137
  }>
107
138
  }
@@ -132,6 +163,8 @@ export interface CreateTemplateInput {
132
163
  * ratio/duration/model 放在 generation_configs 每项里。 */
133
164
  export interface CreateVideoTemplateInput {
134
165
  zh_name: string
166
+ /** 对外调用标识,视频模板硬必填(服务端 validateCore required=['zh_name','slug']),全局唯一 */
167
+ slug: string
135
168
  category?: string
136
169
  description?: string
137
170
  sample_video_url?: string | null
@@ -405,6 +438,8 @@ export class StudioClient {
405
438
  if (opts.reference_images?.length) body.reference_images = opts.reference_images
406
439
  if (opts.quality) body.quality = opts.quality
407
440
  if (opts.background) body.background = opts.background
441
+ // 项目归档:中台只对账户身份收 workspace_id(租户身份忽略),CLI 不做二次校验
442
+ if (opts.workspace_id) body.workspace_id = opts.workspace_id
408
443
  const r = await this.request('generate', {
409
444
  method: 'POST',
410
445
  headers: { 'Content-Type': 'application/json' },
@@ -419,6 +454,49 @@ export class StudioClient {
419
454
  return r
420
455
  }
421
456
 
457
+ // ── 工作区(项目)与项目素材库 ──
458
+ // 平台 → 账户 → 工作区三层归属;素材挂工作区,换业务换工作区,互不污染。
459
+
460
+ /** 列当前账户的工作区(含生成统计) */
461
+ async workspaces(): Promise<Workspace[]> {
462
+ return this.request('workspaces')
463
+ }
464
+
465
+ /** 新建工作区(最多 5 个,超了服务端会 400) */
466
+ async createWorkspace(name: string): Promise<Workspace> {
467
+ return this.request('workspaces', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) })
468
+ }
469
+
470
+ /** 列某工作区的素材库 */
471
+ async workspaceAssets(workspaceId: string): Promise<WorkspaceAsset[]> {
472
+ return this.request(`workspace-assets?workspace_id=${encodeURIComponent(workspaceId)}`)
473
+ }
474
+
475
+ /** 上传素材进工作区素材库。素材是母版,**不做视觉压缩**(fileForm 那套压缩是给模型看的) */
476
+ async addWorkspaceAsset(input: {
477
+ file: string
478
+ workspaceId: string
479
+ name?: string
480
+ tags?: string[]
481
+ }): Promise<WorkspaceAsset> {
482
+ const blob = new Blob([new Uint8Array(readFileSync(input.file))])
483
+ const fd = new FormData()
484
+ fd.append('file', blob, basename(input.file))
485
+ fd.append('workspace_id', input.workspaceId)
486
+ if (input.name) fd.append('name', input.name)
487
+ for (const t of input.tags || []) fd.append('tags', t)
488
+ return this.request('workspace-assets', { method: 'POST', body: fd })
489
+ }
490
+
491
+ /** 删除素材(硬删:R2 对象 + 记录) */
492
+ async deleteWorkspaceAsset(id: string): Promise<void> {
493
+ await this.request('workspace-assets', {
494
+ method: 'DELETE',
495
+ headers: { 'Content-Type': 'application/json' },
496
+ body: JSON.stringify({ id }),
497
+ })
498
+ }
499
+
422
500
  /**
423
501
  * 列出当前身份名下的出图工作流(不传 id,走同一个 jobs 端点的集合语义)。
424
502
  * 范围由鉴权凭证决定:个人 token 只看得到自己出的图;租户 apiKey 看得到自己业务下的全部记录。
@@ -467,19 +545,22 @@ export class StudioClient {
467
545
  duration?: number
468
546
  /** 图生视频:首帧/参考图 URL(中台内部自动上传垫图后拿到 URL 再传这里) */
469
547
  image_url?: string
470
- template_id?: string
471
- input?: string | Record<string, string>
472
- callback_url?: string
473
- }): Promise<{ jobId: string; upstreamTaskId?: string }> {
474
- const body: Record<string, unknown> = {}
475
- if (opts.prompt) body.prompt = opts.prompt
476
- if (opts.model) body.model = opts.model
477
- if (opts.ratio) body.ratio = opts.ratio
478
- if (opts.duration != null) body.duration = opts.duration
479
- if (opts.image_url) body.image_url = opts.image_url
480
- if (opts.template_id) body.template_id = opts.template_id
481
- if (opts.input) body.input = opts.input
482
- if (opts.callback_url) body.callback_url = opts.callback_url
548
+ template_id?: string
549
+ input?: string | Record<string, string>
550
+ callback_url?: string
551
+ /** 项目归档(账户身份才生效) */
552
+ workspace_id?: string
553
+ }): Promise<{ jobId: string; upstreamTaskId?: string }> {
554
+ const body: Record<string, unknown> = {}
555
+ if (opts.prompt) body.prompt = opts.prompt
556
+ if (opts.model) body.model = opts.model
557
+ if (opts.ratio) body.ratio = opts.ratio
558
+ if (opts.duration != null) body.duration = opts.duration
559
+ if (opts.image_url) body.image_url = opts.image_url
560
+ if (opts.template_id) body.template_id = opts.template_id
561
+ if (opts.input) body.input = opts.input
562
+ if (opts.callback_url) body.callback_url = opts.callback_url
563
+ if (opts.workspace_id) body.workspace_id = opts.workspace_id
483
564
  const r = await this.request('videos', {
484
565
  method: 'POST',
485
566
  headers: { 'Content-Type': 'application/json' },
@@ -1,5 +1,6 @@
1
1
  /** museav gen —— 出图 / 出视频(核心命令) */
2
2
  import type { StudioClient } from '../client.js'
3
+ import { resolveWorkspace } from './projects.js'
3
4
 
4
5
  /** 与中台/各租户后台口径一致:一次最多 5 张参考图 */
5
6
  const MAX_REFS = 5
@@ -15,6 +16,7 @@ export async function gen(client: StudioClient, opts: {
15
16
  quality?: string
16
17
  ref?: string[] // 可重复:--ref a.jpg --ref b.jpg,顺序即「图片1、图片2…」
17
18
  transparent?: boolean // 透明背景 PNG;能不能做由中台按上游能力判定,做不了会明确报错
19
+ project?: string // 工作区 id|名:生成结果归档进该项目(账户身份才生效)
18
20
  // 视频
19
21
  video?: boolean
20
22
  duration?: number
@@ -63,6 +65,13 @@ export async function gen(client: StudioClient, opts: {
63
65
  if (refPaths.length) {
64
66
  const urls: string[] = []
65
67
  for (const [i, refPath] of refPaths.entries()) {
68
+ // http(s) 直链(典型来源:museav projects assets 的素材库 URL)本身就是
69
+ // 中台 CDN 地址,直接当参考图用,不走上传
70
+ if (/^https?:\/\//.test(refPath)) {
71
+ urls.push(refPath)
72
+ process.stderr.write(` 图片${i + 1} 直链: ${refPath}\n`)
73
+ continue
74
+ }
66
75
  process.stderr.write(`上传垫图 [图片${i + 1}] ${refPath} ...\n`)
67
76
  const up = await client.uploadRef(refPath)
68
77
  urls.push(up.url)
@@ -72,6 +81,9 @@ export async function gen(client: StudioClient, opts: {
72
81
  referenceImages = urls.length > 1 ? urls : undefined
73
82
  }
74
83
 
84
+ // 项目归档:--project 解析成 workspace_id(名字/ id 都行),租户身份时中台会忽略
85
+ const workspaceId = opts.project ? (await resolveWorkspace(client, opts.project)).id : undefined
86
+
75
87
  // ── 视频模式:走 /api/videos 独立链路 ──
76
88
  if (opts.video) {
77
89
  if (opts.quality) throw new Error('--quality 仅图片出图支持')
@@ -87,6 +99,7 @@ export async function gen(client: StudioClient, opts: {
87
99
  image_url: referenceImage,
88
100
  template_id: opts.template,
89
101
  input: templateFields,
102
+ workspace_id: workspaceId,
90
103
  })
91
104
  process.stderr.write(`视频任务已提交: ${jobId}\n生成中(视频通常 1-5 分钟)...\n`)
92
105
  const result = await client.waitVideo(jobId, (status) => {
@@ -123,6 +136,7 @@ export async function gen(client: StudioClient, opts: {
123
136
  // 开关 → 枚举:CLI 这层用布尔开关最顺手,中台契约是 background: transparent|opaque
124
137
  // (跟上游 gpt-image 的参数同名同值)。不传就不发,行为跟以前完全一样。
125
138
  background: opts.transparent ? 'transparent' : undefined,
139
+ workspace_id: workspaceId,
126
140
  },
127
141
  (status) => {
128
142
  if (status === 'processing') process.stderr.write('生成中...\r')
@@ -0,0 +1,95 @@
1
+ /** museav compress / remove-bg —— 本地图像工具箱。
2
+ * 纯本地、免登录、不碰中台;stdout 只出产物路径,统计与进度打 stderr。
3
+ * 代码零平台假设(路径全走 node:path/os,无 shell 展开、无 Unix-only 命令),macOS / Windows 通用。 */
4
+ import { stat, writeFile } from 'node:fs/promises'
5
+ import { basename, dirname, extname, join } from 'node:path'
6
+ import { removeBackgroundLocal, BG_MODELS, type BgModelKey } from '../local-bg.js'
7
+
8
+ async function fileExists(path: string): Promise<boolean> {
9
+ try {
10
+ return (await stat(path)).isFile()
11
+ } catch {
12
+ return false
13
+ }
14
+ }
15
+
16
+ function fmtBytes(n: number): string {
17
+ return n >= 1048576 ? `${(n / 1048576).toFixed(2)}MB` : `${(n / 1024).toFixed(1)}KB`
18
+ }
19
+
20
+ /** 默认输出路径:同目录 <名字>-<后缀>.<新扩展名>。绝不覆写输入文件 */
21
+ export function defaultOut(input: string, suffix: string, newExt?: string): string {
22
+ const ext = newExt || extname(input).slice(1) || 'png'
23
+ return join(dirname(input), `${basename(input, extname(input))}-${suffix}.${ext}`)
24
+ }
25
+
26
+ export interface CompressOpts {
27
+ out?: string
28
+ maxEdge?: string
29
+ quality?: string
30
+ format?: string
31
+ overwrite?: boolean
32
+ }
33
+
34
+ export async function compressCmd(input: string, opts: CompressOpts): Promise<void> {
35
+ if (!(await fileExists(input))) throw new Error(`文件不存在: ${input}`)
36
+
37
+ let sharp: any
38
+ try {
39
+ const m = await import('sharp')
40
+ sharp = (m as any).default ?? m
41
+ } catch {
42
+ throw new Error('sharp 不可用(压缩依赖它)。重装 CLI 即可补上:npm install -g museav-cli')
43
+ }
44
+
45
+ const format = (opts.format || '').toLowerCase()
46
+ if (format && !['jpg', 'png', 'webp'].includes(format)) throw new Error('--format 只支持 jpg / png / webp')
47
+ const quality = opts.quality ? Number(opts.quality) : 82
48
+ if (!Number.isFinite(quality) || quality < 1 || quality > 100) throw new Error('--quality 必须是 1-100')
49
+ const maxEdge = opts.maxEdge ? Number(opts.maxEdge) : 0
50
+ if (opts.maxEdge && (!Number.isFinite(maxEdge) || maxEdge < 16)) throw new Error('--max-edge 至少 16px')
51
+
52
+ const meta = await sharp(input).metadata()
53
+ // 不指定 --format 时保持原格式;不在三之列的(tiff/bmp/heic…)统一转 jpg
54
+ const srcFormat = String(meta.format || '')
55
+ const target = format || (srcFormat === 'png' ? 'png' : srcFormat === 'webp' ? 'webp' : 'jpg')
56
+
57
+ let pipeline = sharp(input).rotate() // 尊重 EXIF 方向
58
+ if (maxEdge) pipeline = pipeline.resize({ width: maxEdge, height: maxEdge, fit: 'inside' })
59
+ if (target === 'jpg') pipeline = pipeline.jpeg({ quality, mozjpeg: true })
60
+ else if (target === 'webp') pipeline = pipeline.webp({ quality })
61
+ else pipeline = pipeline.png({ compressionLevel: 9 })
62
+
63
+ const outPath = opts.out || defaultOut(input, 'min', target === 'jpg' && srcFormat === 'jpeg' ? 'jpg' : target)
64
+ if ((await fileExists(outPath)) && !opts.overwrite) {
65
+ throw new Error(`输出已存在(用 --overwrite 覆盖或 --out 换路径): ${outPath}`)
66
+ }
67
+ const buf = await pipeline.toBuffer()
68
+ await writeFile(outPath, buf)
69
+
70
+ const before = (await stat(input)).size
71
+ process.stderr.write(`✅ ${fmtBytes(before)} → ${fmtBytes(buf.length)}(省 ${Math.max(0, Math.round((1 - buf.length / before) * 100))}%,${target.toUpperCase()})\n`)
72
+ console.log(outPath)
73
+ }
74
+
75
+ export interface RemoveBgOpts {
76
+ out?: string
77
+ model?: string
78
+ overwrite?: boolean
79
+ }
80
+
81
+ export async function removeBgCmd(input: string, opts: RemoveBgOpts): Promise<void> {
82
+ if (!(await fileExists(input))) throw new Error(`文件不存在: ${input}`)
83
+ const modelKey = (opts.model || 'isnet') as BgModelKey
84
+ if (!(modelKey in BG_MODELS)) throw new Error(`--model 只支持 ${Object.keys(BG_MODELS).join(' / ')}`)
85
+
86
+ const start = Date.now()
87
+ const png = await removeBackgroundLocal(input, modelKey)
88
+ const outPath = opts.out || defaultOut(input, 'nobg', 'png')
89
+ if ((await fileExists(outPath)) && !opts.overwrite) {
90
+ throw new Error(`输出已存在(用 --overwrite 覆盖或 --out 换路径): ${outPath}`)
91
+ }
92
+ await writeFile(outPath, png)
93
+ process.stderr.write(`✅ 抠图完成(${BG_MODELS[modelKey].label},用时 ${((Date.now() - start) / 1000).toFixed(1)}s,${fmtBytes(png.length)})\n`)
94
+ console.log(outPath)
95
+ }
@@ -5,6 +5,7 @@
5
5
  * 不需要额外传租户/用户 id——你拿的是谁的凭证,就是谁的数据。
6
6
  */
7
7
  import type { StudioClient, Job } from '../client.js'
8
+ import { resolveWorkspace } from './projects.js'
8
9
 
9
10
  const STATUS_MARK: Record<Job['status'], string> = {
10
11
  pending: '⏳',
@@ -13,9 +14,14 @@ const STATUS_MARK: Record<Job['status'], string> = {
13
14
  failed: '❌',
14
15
  }
15
16
 
16
- export async function jobs(client: StudioClient, opts: { limit?: string; status?: Job['status'] }): Promise<void> {
17
+ export async function jobs(client: StudioClient, opts: { limit?: string; status?: Job['status']; project?: string }): Promise<void> {
17
18
  const limit = opts.limit ? Number(opts.limit) : 20
18
- const list = await client.listJobs({ limit, status: opts.status })
19
+ let list = await client.listJobs({ limit, status: opts.status })
20
+ // --project 客户端过滤:服务端 jobs 不认 workspace 参数,在最近 50 条内筛
21
+ if (opts.project) {
22
+ const ws = await resolveWorkspace(client, opts.project)
23
+ list = list.filter((j) => (j as unknown as { workspace_id?: string | null }).workspace_id === ws.id)
24
+ }
19
25
 
20
26
  if (!list.length) {
21
27
  process.stderr.write('没有找到工作流记录\n')
@@ -0,0 +1,80 @@
1
+ /** museav projects —— 工作区(项目)与项目素材库。
2
+ * 层级:平台 → 账户 → 工作区;素材挂工作区,业务隔离互不污染。
3
+ * 「人像库的工作区出模特图、产品库的工作区出电商图」的载体就是这里。 */
4
+ import type { StudioClient, Workspace } from '../client.js'
5
+
6
+ /** --project <id|名> 的解析:id 精确命中,否则按名称匹配;找不到/歧义时把可选项列出来 */
7
+ export async function resolveWorkspace(client: StudioClient, idOrName: string): Promise<Workspace> {
8
+ const list = await client.workspaces()
9
+ const key = String(idOrName || '').trim()
10
+ if (!key) throw new Error('缺少 --project(工作区 id 或名称)')
11
+ const byId = list.find((w) => w.id === key)
12
+ if (byId) return byId
13
+ const byName = list.filter((w) => w.name === key)
14
+ if (byName.length === 1) return byName[0]
15
+ if (byName.length > 1) throw new Error(`重名工作区「${key}」,请用 id 指定:\n${list.map((w) => ` ${w.id} ${w.name}`).join('\n')}`)
16
+ throw new Error(`没有工作区「${key}」。现有:\n${list.map((w) => ` ${w.id} ${w.name}`).join('\n')}\n(museav projects create --name 可新建)`)
17
+ }
18
+
19
+ export async function projects(client: StudioClient): Promise<void> {
20
+ const list = await client.workspaces()
21
+ if (!list.length) {
22
+ process.stderr.write('还没有工作区(museav projects create --name 新建)\n')
23
+ return
24
+ }
25
+ process.stderr.write(`工作区(${list.length} 个):\n`)
26
+ for (const w of list) {
27
+ process.stderr.write(
28
+ ` ${w.id} ${w.name.padEnd(16)} 出图 ${w.gen_done ?? 0}/${w.gen_total ?? 0}${w.brand ? ` brand:${w.brand}` : ''}\n`,
29
+ )
30
+ }
31
+ process.stderr.write(`\n素材库: museav projects assets --project <id|名>\n出图归档: museav gen --project <id|名> ...\n`)
32
+ // stdout 只出 id,便于脚本解析
33
+ console.log(list.map((w) => w.id).join('\n'))
34
+ }
35
+
36
+ export async function createProject(client: StudioClient, opts: { name: string }): Promise<void> {
37
+ const name = (opts.name || '').trim()
38
+ if (!name) throw new Error('--name 必填')
39
+ const row = await client.createWorkspace(name)
40
+ process.stderr.write(`✅ 工作区已建:${row.id} ${row.name}\n`)
41
+ process.stderr.write(`传素材: museav projects assets add <file> --project ${row.id}\n`)
42
+ console.log(row.id)
43
+ }
44
+
45
+ export async function listAssets(client: StudioClient, opts: { project?: string }): Promise<void> {
46
+ const ws = await resolveWorkspace(client, opts.project || '')
47
+ const assets = await client.workspaceAssets(ws.id)
48
+ process.stderr.write(`「${ws.name}」素材库(${assets.length} 条):\n`)
49
+ for (const a of assets) {
50
+ const tag = a.tags?.length ? `[${a.tags.join(',')}]` : ''
51
+ process.stderr.write(` ${a.id} ${(a.name || '(未命名)').padEnd(16)} ${a.media_type.padEnd(5)} ${tag}\n`)
52
+ process.stderr.write(` ${a.cdn_url}\n`)
53
+ }
54
+ process.stderr.write(`\n垫图出图: museav gen --project ${ws.id} --ref <素材URL> --prompt '...'\n`)
55
+ // stdout:id<TAB>url 每行一条,agent 拿去直接当 --ref 用
56
+ console.log(assets.map((a) => `${a.id}\t${a.cdn_url}`).join('\n'))
57
+ }
58
+
59
+ export async function addAsset(
60
+ client: StudioClient,
61
+ file: string,
62
+ opts: { project?: string; name?: string; tag?: string[] },
63
+ ): Promise<void> {
64
+ const ws = await resolveWorkspace(client, opts.project || '')
65
+ const row = await client.addWorkspaceAsset({
66
+ file,
67
+ workspaceId: ws.id,
68
+ name: opts.name,
69
+ tags: opts.tag || [],
70
+ })
71
+ process.stderr.write(`✅ 已入「${ws.name}」素材库:${row.name || '(未命名)'} ${row.media_type}\n`)
72
+ process.stderr.write(`${row.cdn_url}\n`)
73
+ console.log(row.cdn_url)
74
+ }
75
+
76
+ export async function removeAsset(client: StudioClient, opts: { id: string }): Promise<void> {
77
+ await client.deleteWorkspaceAsset(opts.id)
78
+ process.stderr.write(`✅ 素材已删:${opts.id}\n`)
79
+ console.log(opts.id)
80
+ }
@@ -1,11 +1,43 @@
1
- /** museav reverse —— 图片逆向(SCULPT 六要素反推 prompt) */
2
- import type { StudioClient } from '../client.js'
1
+ /** museav reverse —— 图片逆向(SCULPT 六要素反推 prompt)。
2
+ * 主路是本地 Ollama(qwen3-vl),快、零成本、无需登录;中台 API 是回落路,走回落时会明确提示较慢。
3
+ * client 懒构造(getClient):本地路成功就完全不碰中台凭证。
4
+ * 本地系统的 AI 能力统一收口在这个 CLI,reverse 是第一个本地化的能力。 */
5
+ import type { StudioClient, ReverseResult } from '../client.js'
6
+ import { checkLocalVlm, reverseLocally, LOCAL_VLM_MODEL } from '../local-vision.js'
3
7
 
4
- export async function reverse(client: StudioClient, input: string): Promise<void> {
5
- // 输入是文件路径还是 URL
8
+ export async function reverse(
9
+ getClient: () => StudioClient,
10
+ input: string,
11
+ opts: { api?: boolean } = {},
12
+ ): Promise<void> {
6
13
  const isUrl = /^https?:\/\//.test(input)
14
+
15
+ if (!opts.api && !isUrl) {
16
+ const status = await checkLocalVlm()
17
+ if (status.running && status.modelPresent) {
18
+ try {
19
+ const start = Date.now()
20
+ const result = await reverseLocally(input)
21
+ process.stderr.write(`✓ 本地 Ollama(${LOCAL_VLM_MODEL})用时 ${((Date.now() - start) / 1000).toFixed(1)}s\n`)
22
+ renderReverse(result)
23
+ return
24
+ } catch (e) {
25
+ process.stderr.write(`⚠ 本地读图失败(${e instanceof Error ? e.message : e}),回落中台 API —— 速度较慢,请耐心等待\n`)
26
+ }
27
+ } else {
28
+ process.stderr.write(`⚠ 本地读图不可用(${status.reason}),回落中台 API —— 速度较慢,请耐心等待\n`)
29
+ }
30
+ } else if (!opts.api && isUrl) {
31
+ process.stderr.write(`ℹ URL 输入走中台 API(本地路只收文件路径)\n`)
32
+ }
33
+
34
+ const client = getClient()
7
35
  const result = await client.reverse(isUrl ? { imageUrl: input } : { file: input })
36
+ renderReverse(result)
37
+ }
8
38
 
39
+ /** 两条路产出同构,渲染只写一份 */
40
+ function renderReverse(result: ReverseResult): void {
9
41
  process.stderr.write(`✅ 逆向完成\n\n`)
10
42
  process.stderr.write(`风格: ${result.zh_name || '-'} 比例: ${result.aspect_ratio}\n`)
11
43
  process.stderr.write(`标签: ${result.style_tags.join(', ')}\n\n`)
@@ -19,7 +19,8 @@ export async function templates(client: StudioClient, opts: { category?: string;
19
19
  process.stderr.write(`可用模板(${list.length} 个):\n`)
20
20
  for (const t of list) {
21
21
  const cfg = t.generation_configs?.find((c) => c.is_default) || t.generation_configs?.[0]
22
- const fields = cfg?.params_json?.fields || []
22
+ // fields 新契约在 config 顶层(CLI 自己 create 就写顶层),老数据在 params_json 里——两种都兜
23
+ const fields = cfg?.fields || cfg?.params_json?.fields || []
23
24
  const fieldHint = fields.length ? `字段:${fields.map((f) => f.key).join(',')}` : ''
24
25
  process.stderr.write(
25
26
  ` ${t.id.padEnd(38)} ${(t.zh_name || '').padEnd(16)} ${(t.category || '').padEnd(10)} ${(t.ratio || '').padEnd(6)} ${typeTag(t).padEnd(8)} ${fieldHint.padEnd(20)} ${tag(t)}\n`,
@@ -19,8 +19,9 @@ export async function videoTemplates(client: StudioClient, opts: { category?: st
19
19
  const cfg = t.generation_configs?.find((c) => c.is_default) || t.generation_configs?.[0]
20
20
  const modelHint = cfg?.model ? `模型:${cfg.model}` : ''
21
21
  const ratioHint = t.ratio || ''
22
- const fieldCount = cfg?.params_json?.fields?.length || 0
23
- const fieldHint = fieldCount ? `字段:${cfg!.params_json!.fields!.map((f) => f.key).join(',')}` : ''
22
+ // fields 新契约在 config 顶层,老数据在 params_json 里——两种都兜,否则自己建的模板自己列不出来
23
+ const fields = cfg?.fields || cfg?.params_json?.fields || []
24
+ const fieldHint = fields.length ? `字段:${fields.map((f) => f.key).join(',')}` : ''
24
25
  const sampleHint = t.sample_video_url ? '有参考视频' : ''
25
26
  process.stderr.write(
26
27
  ` ${t.id.padEnd(38)} ${(t.zh_name || '').padEnd(20)} ${(t.category || '').padEnd(10)} ${ratioHint.padEnd(6)} ${modelHint.padEnd(30)} ${fieldHint.padEnd(24)} ${sampleHint.padEnd(10)} ${tag(t)}\n`,
@@ -36,6 +37,8 @@ export async function videoTemplates(client: StudioClient, opts: { category?: st
36
37
 
37
38
  interface CreateVideoTemplateOpts {
38
39
  name: string
40
+ /** 对外调用标识,全局唯一。不给就自动生成一个(vt- 前缀) */
41
+ slug?: string
39
42
  prompt: string
40
43
  category?: string
41
44
  description?: string
@@ -53,25 +56,32 @@ export async function createVideoTemplate(client: StudioClient, opts: CreateVide
53
56
  if (!opts.name?.trim()) throw new Error('--name 必填')
54
57
  if (!opts.prompt?.trim()) throw new Error('--prompt 必填,占位符用 {key} 形式,如 "{product} 在 {scene} 中展示"')
55
58
 
59
+ // slug 是视频模板的硬必填(服务端 required=['zh_name','slug']),漏了必 400。
60
+ // 中文名大多是中文没法直接转 slug,不给 --slug 就生成一个 vt- 短标识,撞了让服务端报出来再换。
61
+ const slug = (opts.slug || `vt-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`).trim()
62
+ if (!/^[\w-]+$/.test(slug)) throw new Error('--slug 只能包含字母、数字、下划线和连字符')
63
+
56
64
  // 占位符必须声明 fields(中台 validateConfig 硬校验:prompt 里有 {key} 但没 fields 会被拒)
57
65
  const keys = Array.from(new Set(Array.from(opts.prompt.matchAll(/\{(\w+)\}/g), (m) => m[1])))
58
66
  const fields = keys.map((key) => ({ key, label: key }))
59
67
 
60
68
  const cfg: Record<string, unknown> = {
61
- model: opts.model || 'seedance-2',
69
+ // 默认 auto:交给中台路由按 ratio/duration 挑档次。锁死具体模型得自己保证参数配得上它
70
+ model: opts.model || 'auto',
62
71
  prompt_template: opts.prompt,
63
72
  is_default: true,
64
73
  }
65
74
  if (fields.length) cfg.fields = fields
66
75
  if (opts.duration) {
67
76
  const d = Number(opts.duration)
68
- if (!Number.isFinite(d) || d < 4 || d > 15) throw new Error('--duration 必须是 4-15 之间的数字(秒)')
77
+ if (!Number.isFinite(d) || d < 4 || d > 30) throw new Error('--duration 必须是 4-30 之间的数字(秒;Seedance 2.0 系上限 15,2.5 到 30,具体由中台按模型校验)')
69
78
  cfg.duration = d
70
79
  }
71
80
  if (opts.ratio) cfg.aspect_ratio = opts.ratio
72
81
 
73
82
  const row = await client.createVideoTemplate({
74
83
  zh_name: opts.name,
84
+ slug,
75
85
  category: opts.category,
76
86
  description: opts.description,
77
87
  sample_video_url: opts.sampleVideo || null,
@@ -80,6 +90,7 @@ export async function createVideoTemplate(client: StudioClient, opts: CreateVide
80
90
  })
81
91
 
82
92
  process.stderr.write(`✅ 视频模板已建:${row.id}\n`)
93
+ process.stderr.write(`slug: ${slug}\n`)
83
94
  process.stderr.write(`归属:${row.tenant_id ? '当前租户(其他租户看不到)' : '平台共享(所有租户可见)'}\n`)
84
95
  process.stderr.write(`模型: ${cfg.model} 时长: ${cfg.duration || '模板默认'} 比例: ${cfg.aspect_ratio || '模板默认'}\n`)
85
96
  if (fields.length) process.stderr.write(`占位符字段: ${fields.map((f) => f.key).join(', ')}\n`)