museav-cli 2.0.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.
Files changed (70) hide show
  1. package/.github/workflows/ci.yml +19 -0
  2. package/.github/workflows/publish.yml +79 -0
  3. package/AGENTS.md +90 -0
  4. package/CHANGELOG.md +49 -0
  5. package/LICENSE +21 -0
  6. package/README.md +490 -0
  7. package/SECURITY.md +11 -0
  8. package/dist/client.d.ts +387 -0
  9. package/dist/client.js +372 -0
  10. package/dist/commands/assets.d.ts +14 -0
  11. package/dist/commands/assets.js +30 -0
  12. package/dist/commands/balance.d.ts +3 -0
  13. package/dist/commands/balance.js +11 -0
  14. package/dist/commands/bind-feishu.d.ts +3 -0
  15. package/dist/commands/bind-feishu.js +63 -0
  16. package/dist/commands/gen.d.ts +16 -0
  17. package/dist/commands/gen.js +88 -0
  18. package/dist/commands/image-to-template.d.ts +24 -0
  19. package/dist/commands/image-to-template.js +111 -0
  20. package/dist/commands/jobs.d.ts +11 -0
  21. package/dist/commands/jobs.js +27 -0
  22. package/dist/commands/login.d.ts +3 -0
  23. package/dist/commands/login.js +67 -0
  24. package/dist/commands/models.d.ts +3 -0
  25. package/dist/commands/models.js +9 -0
  26. package/dist/commands/products.d.ts +9 -0
  27. package/dist/commands/products.js +16 -0
  28. package/dist/commands/reverse.d.ts +3 -0
  29. package/dist/commands/reverse.js +16 -0
  30. package/dist/commands/skills.d.ts +5 -0
  31. package/dist/commands/skills.js +22 -0
  32. package/dist/commands/templates.d.ts +28 -0
  33. package/dist/commands/templates.js +79 -0
  34. package/dist/commands/upload.d.ts +9 -0
  35. package/dist/commands/upload.js +8 -0
  36. package/dist/commands/video-templates.d.ts +21 -0
  37. package/dist/commands/video-templates.js +71 -0
  38. package/dist/commands/welcome.d.ts +6 -0
  39. package/dist/commands/welcome.js +93 -0
  40. package/dist/commands/whoami.d.ts +3 -0
  41. package/dist/commands/whoami.js +14 -0
  42. package/dist/config.d.ts +31 -0
  43. package/dist/config.js +93 -0
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.js +292 -0
  46. package/dist/tenant-client.d.ts +31 -0
  47. package/dist/tenant-client.js +84 -0
  48. package/package.json +55 -0
  49. package/src/client.ts +636 -0
  50. package/src/commands/assets.ts +47 -0
  51. package/src/commands/balance.ts +12 -0
  52. package/src/commands/bind-feishu.ts +77 -0
  53. package/src/commands/gen.ts +111 -0
  54. package/src/commands/image-to-template.ts +150 -0
  55. package/src/commands/jobs.ts +37 -0
  56. package/src/commands/login.ts +80 -0
  57. package/src/commands/models.ts +12 -0
  58. package/src/commands/products.ts +38 -0
  59. package/src/commands/reverse.ts +21 -0
  60. package/src/commands/skills.ts +29 -0
  61. package/src/commands/templates.ts +98 -0
  62. package/src/commands/upload.ts +18 -0
  63. package/src/commands/video-templates.ts +89 -0
  64. package/src/commands/welcome.ts +108 -0
  65. package/src/commands/whoami.ts +19 -0
  66. package/src/config.ts +114 -0
  67. package/src/index.ts +318 -0
  68. package/src/tenant-client.ts +90 -0
  69. package/src/types/update-notifier.d.ts +31 -0
  70. package/tsconfig.json +19 -0
package/src/client.ts ADDED
@@ -0,0 +1,636 @@
1
+ /**
2
+ * StudioClient —— studio 中台 API 客户端
3
+ *
4
+ * 所有出图/逆向/上传能力都封装在这里。CLI commands 和编程调用共用这一个 class。
5
+ *
6
+ * 用法:
7
+ * const studio = new StudioClient({ baseUrl, apiKey })
8
+ * const job = await studio.generateAndWait({ prompt: '一只猫' })
9
+ * console.log(job.cdn_url)
10
+ */
11
+ import { readFileSync } from 'node:fs'
12
+ import { basename } from 'node:path'
13
+
14
+ /**
15
+ * 客户端自报身份 —— 中台靠它把 gen_jobs.channel 记成 'cli',报错告警也靠它定位调用方。
16
+ *
17
+ * 必要性:同一把租户 apiKey 既可能来自业务方后端,也可能来自有人在终端跑本 CLI;
18
+ * 同一个个人 JWT 既可能来自网页也可能来自这里。只看凭证分不出渠道,必须自报。
19
+ * 版本号从 package.json 读,随发版自动跟随;读不到就退化成不带版本(仍能识别为 cli)。
20
+ */
21
+ const CLIENT_ID = (() => {
22
+ try {
23
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'))
24
+ return `museav-cli/${pkg.version}`
25
+ } catch {
26
+ return 'museav-cli'
27
+ }
28
+ })()
29
+
30
+ /**
31
+ * 自报身份的头名。**过渡期两个头一起发,值都是 CLIENT_ID。**
32
+ *
33
+ * X-Museav-Client —— 新头,跟产品名一致,长期只留这一个。
34
+ * X-Studio-Client —— 旧头,头名里还带着已经废弃的 "Studio" 叫法,纯为兼容保留。
35
+ *
36
+ * 为什么两个都发:中台的渠道识别(detectChannel)与报错上下文(_middleware 的
37
+ * requestContext)现在读的是旧头,且对值做前缀匹配。CLI 单方面改名,中台就会把 CLI
38
+ * 的调用记成 browser/api,渠道统计当场失真。过渡期中台两个头都读,等所有客户端都升上来
39
+ * 之后,中台先停读旧头,这里再把 X-Studio-Client 删掉——那时删是纯清理,不影响任何人。
40
+ */
41
+ const CLIENT_HEADERS: Record<string, string> = {
42
+ 'X-Museav-Client': CLIENT_ID,
43
+ 'X-Studio-Client': CLIENT_ID,
44
+ // 中台还有一条 UA 兜底匹配(有人只改 UA 不带自报头时也能认出是 CLI)。
45
+ // Node 默认 UA 是 "node",什么信息都没有,这里显式带上同一个身份串。
46
+ 'User-Agent': CLIENT_ID,
47
+ }
48
+
49
+ export interface GenerateOptions {
50
+ /** 自己写完整提示词。与 skill_slug / template_id 三选一 */
51
+ prompt?: string
52
+ /**
53
+ * 用中台技能出图(技能黑盒):提示词正文在服务端展开,不下发。
54
+ * 查找顺序:自己的私有技能 → 所属租户的专属模板 → 公共技能库。
55
+ * 与 prompt / template_id 三选一。
56
+ */
57
+ skill_slug?: string
58
+ /** 配合 skill_slug 的一句业务描述,如「米白色针织衫」。不给则按技能规范自由发挥 */
59
+ input?: string
60
+ /**
61
+ * 用图片模板出图(模板黑盒):提示词模板在服务端展开,确定性字符串替换,不经模型、
62
+ * 不产生 chat 成本。模板清单用 museav templates 查。与 prompt / skill_slug 三选一。
63
+ */
64
+ template_id?: string
65
+ /** 配合 template_id 的占位符取值,如 {artist:'王嘉尔', city:'南京'};模板没有占位符则不用传 */
66
+ template_fields?: Record<string, string>
67
+ ratio?: string
68
+ model?: string
69
+ reference_image?: string
70
+ quality?: 'low' | 'medium' | 'high'
71
+ }
72
+
73
+ /** 图片/文字模板清单项(GET /api/templates,template_type=image|article) */
74
+ export interface TemplateOption {
75
+ id: string
76
+ category: string
77
+ zh_name: string
78
+ description?: string
79
+ ratio: string
80
+ /** image=图片模板 / article=文字模板(视频模板在 videoTemplates()) */
81
+ template_type?: string
82
+ sample_images?: string[] | null
83
+ /** 视频模板专用:参考视频/封面(video-templates 接口返回,租户后台和 CLI 都靠它看参考) */
84
+ sample_video_url?: string | null
85
+ sample_cover_image?: string | null
86
+ /** 归属:自己租户建的 vs 平台共享的(tenant_id 为空) */
87
+ tenant_id: string | null
88
+ generation_configs: Array<{
89
+ model: string
90
+ prompt_template: string
91
+ ref_slots?: string[]
92
+ params_json?: { fields?: Array<{ key: string; label: string; placeholder?: string }> }
93
+ is_default?: boolean
94
+ }>
95
+ }
96
+
97
+ /**
98
+ * 新建模板入参(POST /api/templates)。不传 tenant_id —— 归属完全由服务端根据
99
+ * 调用者身份决定:租户 apiKey 自动打自己的 tenant_id,平台管理员 JWT 建的是
100
+ * tenant_id=null 的平台共享模板,个人账号(无租户、非管理员)会被服务端拒绝(401)。
101
+ */
102
+ export interface CreateTemplateInput {
103
+ zh_name: string
104
+ category?: string
105
+ description?: string
106
+ ratio?: string
107
+ /** image=图片模板 / article=文字模板(不传默认 image,跟中台一致) */
108
+ template_type?: 'image' | 'article'
109
+ generation_configs: Array<{
110
+ model: string
111
+ prompt_template: string
112
+ quality?: string
113
+ /** 表单字段声明——按中台契约放 config 顶层(不是 params_json 里),服务端校验/渲染都读这里 */
114
+ fields?: Array<{ key: string; label: string }>
115
+ is_default?: boolean
116
+ }>
117
+ }
118
+
119
+ /** 新建视频模板入参(POST /api/video-templates)。视频模板字段跟图片不同:
120
+ * ratio/duration/model 放在 generation_configs 每项里。 */
121
+ export interface CreateVideoTemplateInput {
122
+ zh_name: string
123
+ category?: string
124
+ description?: string
125
+ sample_video_url?: string | null
126
+ sample_cover_image?: string | null
127
+ generation_configs: Array<{
128
+ model?: string
129
+ duration?: number
130
+ aspect_ratio?: string
131
+ prompt_template?: string
132
+ is_default?: boolean
133
+ }>
134
+ }
135
+
136
+ /** 技能清单项(GET /api/skills) */
137
+ export interface SkillOption {
138
+ slug: string
139
+ zh_name?: string
140
+ description?: string
141
+ genre?: string
142
+ ratio?: string
143
+ ref_required?: boolean
144
+ /** 私有技能(自己建的) */
145
+ private?: boolean
146
+ /** 所属租户的专属模板 */
147
+ agency?: boolean
148
+ }
149
+
150
+ /**
151
+ * 任务进度里的业务阶段。中台的口径(别自己重新发明):
152
+ * job.status 只有 pending / done / failed —— 业务阶段**不在 status 里**,在 steps 里。
153
+ * 每步 status:running=正在做(可以直接拿来渲染「正在解析图片…」)、ok=做完、fail=卡在这步。
154
+ * 任务结束后不会再有 running。
155
+ */
156
+ export interface JobStep {
157
+ name: string
158
+ status: 'running' | 'ok' | 'fail'
159
+ ms: number | null
160
+ }
161
+
162
+ export interface Job {
163
+ id: string
164
+ status: 'pending' | 'processing' | 'done' | 'failed'
165
+ cdn_url: string | null
166
+ error: string | null
167
+ trace_id?: string
168
+ model?: string
169
+ elapsed_ms?: number
170
+ created_at?: string
171
+ /** 业务阶段进度(中台 sanitizeSteps 脱敏后下发),image-to-template 这类多阶段任务才有 */
172
+ steps?: JobStep[]
173
+ }
174
+
175
+ export interface ReverseResult {
176
+ ok: boolean
177
+ sculpt: Record<string, string>
178
+ prompt: string
179
+ prompt_cn: string
180
+ style_tags: string[]
181
+ aspect_ratio: string
182
+ zh_name?: string
183
+ description?: string
184
+ }
185
+
186
+ /** 图上一处文字的逆向结果(POST /api/image-to-template 的 text_layers 每项) */
187
+ export interface TextLayer {
188
+ role?: string
189
+ content?: string
190
+ position?: string
191
+ font_category?: string
192
+ font_weight?: string
193
+ size_ratio?: number
194
+ color?: string
195
+ treatment?: string
196
+ alignment?: string
197
+ is_variable?: boolean
198
+ variable?: string | null
199
+ }
200
+
201
+ /** 模板表单字段声明(中台 7.3 的 fields 契约) */
202
+ export interface TemplateField {
203
+ key: string
204
+ label: string
205
+ type?: string
206
+ placeholder?: string
207
+ }
208
+
209
+ /**
210
+ * POST /api/image-to-template 的结果本体(同步返回,或异步 job 完成后的 result)。
211
+ *
212
+ * 它是「读图结果 + 文字层 + 模板」三段的叠加:ReverseResult 那几个 key 照常给,
213
+ * 后面几个是这个接口独有的。中台的降级口径:文字层逆向 / 变量化 / 建模板任一步失败,
214
+ * 都只是 prompt_template=null + template_error=<原因>,sculpt / prompt_cn 照常返回——
215
+ * 所以 template 为 null **不等于** 整件事失败,要看 template_error。
216
+ */
217
+ export interface ImageToTemplateResult extends ReverseResult {
218
+ image_category?: string
219
+ text_layers?: TextLayer[]
220
+ /** 变量化后的提示词模具(含 {key} 占位符)。null = 这一步降级了,原因在 template_error */
221
+ prompt_template: string | null
222
+ fields?: TemplateField[]
223
+ /** create_template=true 且建成时非空 */
224
+ template: { id: string; slug: string; zh_name: string; tenant_id: string | null; reference_image?: string | null } | null
225
+ /** 非 null = 模板没建成(读图结果仍然有效) */
226
+ template_error: string | null
227
+ template_warnings?: string[]
228
+ reference_image?: string | null
229
+ }
230
+
231
+ /** 异步提交后的回执(create_template=true 或 async=true 时中台返回这个,不是结果本体) */
232
+ export interface ImageToTemplateJob {
233
+ ok: boolean
234
+ jobId: string
235
+ status: string
236
+ async: boolean
237
+ }
238
+
239
+ /** GET /api/image-to-template?job_id=<uuid> 的返回 */
240
+ export interface ImageToTemplateJobResult {
241
+ ok: boolean
242
+ job_id: string
243
+ status: 'pending' | 'done' | 'failed'
244
+ error: string | null
245
+ elapsed_ms: number | null
246
+ result: ImageToTemplateResult | null
247
+ }
248
+
249
+ /** image-to-template 入参(除图片外都可选,字段名与中台契约 §7.6 一一对应) */
250
+ export interface ImageToTemplateInput {
251
+ /** 本地文件路径,与 imageUrl 二选一 */
252
+ file?: string
253
+ /** 图片 URL,与 file 二选一 */
254
+ imageUrl?: string
255
+ /** 允许出现的变量白名单,可收窄。不允许模型发明白名单外的变量 */
256
+ variables?: string[]
257
+ /** 变量 → 你自己的业务叫法,只影响 fields[].label;key 永远是中台通用语义 */
258
+ variableLabels?: Record<string, string>
259
+ /** true = 直接建好模板(走异步);false/不传 = 只回模板草稿 */
260
+ createTemplate?: boolean
261
+ /** 模板元信息,缺省由中台生成。slug 全局唯一,撞了报错不覆盖 */
262
+ template?: { zh_name?: string; slug?: string; category?: string; domain?: string }
263
+ /** 强制异步(createTemplate=true 时本来就是异步) */
264
+ async?: boolean
265
+ }
266
+
267
+ export interface ModelOption {
268
+ value: string
269
+ label: string
270
+ description?: string
271
+ }
272
+
273
+ export interface Balance {
274
+ /** 数值单位是 ¥(人民币)。字段名带 usd 是历史遗留命名,不代表美元——中台侧不存在汇率换算 */
275
+ balance_usd: number
276
+ /** 租户加价率(0.2 = 加价 20%) */
277
+ markup_pct: number
278
+ checked_at: string
279
+ }
280
+
281
+ export interface MeInfo {
282
+ id: string
283
+ email: string
284
+ nickname?: string
285
+ role: string
286
+ /** 邀请码注册的账户,若该邀请码归属某个租户,这里带出该租户的白标品牌;平台用户(无归属)为 null */
287
+ brand: { name: string; logo: string | null } | null
288
+ gen_total: number
289
+ gen_done: number
290
+ skill_count: number
291
+ generation_used: number | null
292
+ generation_remaining: number | null
293
+ creation_credits: number
294
+ /** 租户 Key(apiKey)调用 /api/me 时返回:identity='tenant' + tenant 信息(2026-08-15) */
295
+ identity?: 'tenant'
296
+ tenant?: { id: string; name: string; nickname: string; logo: string | null }
297
+ /** 飞书绑定 open_id(已绑定飞书时存在,welcome 提示 agent 能认出你) */
298
+ feishu_open_id?: string | null
299
+ }
300
+
301
+ export class StudioClient {
302
+ private baseUrl: string
303
+ private authHeader: Record<string, string>
304
+
305
+ constructor(opts: { baseUrl: string; apiKey?: string; token?: string }) {
306
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, '')
307
+ // 个人用户 JWT 走 Bearer;租户 apikey 走 X-API-Key
308
+ if (opts.token) {
309
+ this.authHeader = { Authorization: `Bearer ${opts.token}` }
310
+ } else if (opts.apiKey) {
311
+ this.authHeader = { 'X-API-Key': opts.apiKey }
312
+ } else {
313
+ throw new Error('需要 token 或 apiKey')
314
+ }
315
+ }
316
+
317
+ private headers(extra: Record<string, string> = {}): Record<string, string> {
318
+ return { ...CLIENT_HEADERS, ...this.authHeader, ...extra }
319
+ }
320
+
321
+ private async request(path: string, init: RequestInit = {}): Promise<any> {
322
+ const url = `${this.baseUrl}/api/${path}`
323
+ const resp = await fetch(url, {
324
+ ...init,
325
+ headers: { ...this.headers(), ...(init.headers as Record<string, string>) },
326
+ })
327
+ const text = await resp.text()
328
+ let body: any
329
+ try { body = JSON.parse(text) } catch { body = { raw: text } }
330
+ if (!resp.ok) {
331
+ const msg = body.error || body.raw || `HTTP ${resp.status}`
332
+ throw new Error(`中台 API /api/${path} 失败: ${msg}`)
333
+ }
334
+ return body
335
+ }
336
+
337
+ /** 可用技能清单:私有 + 所属租户专属模板 + 公共库,服务端已按调用者权限过滤 */
338
+ async skills(): Promise<SkillOption[]> {
339
+ const r = await this.request('skills')
340
+ return Array.isArray(r) ? r : []
341
+ }
342
+
343
+ /** 可用图片/文字模板清单:自己租户建的 + 平台共享的,服务端已按调用者权限过滤。
344
+ * type=image|article 二选一(不传则图片+文字都返回,跟中台默认一致)。 */
345
+ async templates(type?: 'image' | 'article'): Promise<TemplateOption[]> {
346
+ const qs = type ? `?type=${type}` : ''
347
+ const r = await this.request(`templates${qs}`)
348
+ return Array.isArray(r) ? r : []
349
+ }
350
+
351
+ /** 视频模板清单(POST /api/videos 用 template_id)。结构同图片模板的 generation_configs 形态 */
352
+ async videoTemplates(): Promise<TemplateOption[]> {
353
+ const r = await this.request('video-templates')
354
+ return Array.isArray(r) ? r : []
355
+ }
356
+
357
+ /** 新建图片模板。归属(是否关联租户)由服务端根据鉴权身份决定,见 CreateTemplateInput 注释 */
358
+ async createTemplate(input: CreateTemplateInput): Promise<TemplateOption> {
359
+ const r = await this.request('templates', {
360
+ method: 'POST',
361
+ headers: { 'Content-Type': 'application/json' },
362
+ body: JSON.stringify(input),
363
+ })
364
+ return r.row
365
+ }
366
+
367
+ /** 新建视频模板。归属同图片模板:租户 apiKey 自动归租户,平台管理员归平台共享 */
368
+ async createVideoTemplate(input: CreateVideoTemplateInput): Promise<TemplateOption> {
369
+ const r = await this.request('video-templates', {
370
+ method: 'POST',
371
+ headers: { 'Content-Type': 'application/json' },
372
+ body: JSON.stringify(input),
373
+ })
374
+ return r.row
375
+ }
376
+
377
+ /** 提交出图任务,立即返回 jobId */
378
+ async generate(opts: GenerateOptions): Promise<{ jobId: string; trace_id?: string }> {
379
+ // prompt / skill_slug / template_id 三选一:都传时服务端按 prompt > template_id > skill_slug
380
+ // 的优先级取(见服务端 generate.js),这里不替服务端做决定,只保证不凭空造字段
381
+ const body: Record<string, unknown> = {}
382
+ if (opts.prompt) body.prompt = opts.prompt
383
+ if (opts.skill_slug) body.skill_slug = opts.skill_slug
384
+ if (opts.template_id) body.template_id = opts.template_id
385
+ // input 是服务端黑盒展开的入参:skill_slug 配一句话描述,template_id 配占位符取值对象,
386
+ // 两者都写进同一个 input 字段(服务端按类型分支处理),CLI 侧分开成两个选项只是好懂
387
+ if (opts.input) body.input = opts.input
388
+ else if (opts.template_fields) body.input = opts.template_fields
389
+ if (opts.ratio) body.ratio = opts.ratio
390
+ if (opts.model) body.model = opts.model
391
+ if (opts.reference_image) body.reference_image = opts.reference_image
392
+ if (opts.quality) body.quality = opts.quality
393
+ const r = await this.request('generate', {
394
+ method: 'POST',
395
+ headers: { 'Content-Type': 'application/json' },
396
+ body: JSON.stringify(body),
397
+ })
398
+ return { jobId: r.jobId, trace_id: r.trace_id }
399
+ }
400
+
401
+ /** 查单个任务状态 */
402
+ async getJob(id: string): Promise<Job> {
403
+ const r = await this.request(`jobs?id=${encodeURIComponent(id)}`)
404
+ return r
405
+ }
406
+
407
+ /**
408
+ * 列出当前身份名下的出图工作流(不传 id,走同一个 jobs 端点的集合语义)。
409
+ * 范围由鉴权凭证决定:个人 token 只看得到自己出的图;租户 apiKey 看得到自己业务下的全部记录。
410
+ *
411
+ * 服务端 GET /api/jobs 目前只认 id / all 两个 query 参数,固定按 created_at
412
+ * 倒序返回最近 50 条,不支持 limit/status 这类过滤——传了也会被忽略。
413
+ * 所以 limit/status 在这里做客户端过滤:先拿到这最多 50 条,再本地按 status
414
+ * 筛、按 limit 截断。这意味着 --limit 只能在这 50 条以内选,选不到更早的历史。
415
+ */
416
+ async listJobs(opts: { limit?: number; status?: Job['status'] } = {}): Promise<Job[]> {
417
+ const r = await this.request('jobs')
418
+ let list: Job[] = Array.isArray(r) ? r : r.jobs || []
419
+ if (opts.status) list = list.filter((j) => j.status === opts.status)
420
+ if (opts.limit) list = list.slice(0, opts.limit)
421
+ return list
422
+ }
423
+
424
+ /**
425
+ /** 提交出图 + 自动轮询直到完成/失败。
426
+ * onProgress 可选,每次轮询回调一次(用于 CLI 显示进度)。
427
+ */
428
+ async generateAndWait(
429
+ opts: GenerateOptions,
430
+ onProgress?: (status: string) => void,
431
+ intervalMs = 3000,
432
+ maxAttempts = 100,
433
+ ): Promise<Job> {
434
+ const { jobId } = await this.generate(opts)
435
+ for (let i = 0; i < maxAttempts; i++) {
436
+ await sleep(intervalMs)
437
+ const job = await this.getJob(jobId)
438
+ onProgress?.(job.status)
439
+ if (job.status === 'done') return job
440
+ if (job.status === 'failed') {
441
+ throw new Error(`出图失败: ${job.error || '未知原因'}(jobId: ${jobId})`)
442
+ }
443
+ }
444
+ throw new Error(`出图超时(${(maxAttempts * intervalMs) / 1000}s 未返回,jobId: ${jobId})`)
445
+ }
446
+
447
+ /** 提交视频任务(POST /api/videos)——video 走独立链路,不走图片 queue */
448
+ async generateVideo(opts: {
449
+ prompt?: string
450
+ model?: string
451
+ ratio?: string
452
+ duration?: number
453
+ /** 图生视频:首帧/参考图 URL(中台内部自动上传垫图后拿到 URL 再传这里) */
454
+ image_url?: string
455
+ template_id?: string
456
+ input?: string | Record<string, string>
457
+ callback_url?: string
458
+ }): Promise<{ jobId: string; upstreamTaskId?: string }> {
459
+ const body: Record<string, unknown> = {}
460
+ if (opts.prompt) body.prompt = opts.prompt
461
+ if (opts.model) body.model = opts.model
462
+ if (opts.ratio) body.ratio = opts.ratio
463
+ if (opts.duration != null) body.duration = opts.duration
464
+ if (opts.image_url) body.image_url = opts.image_url
465
+ if (opts.template_id) body.template_id = opts.template_id
466
+ if (opts.input) body.input = opts.input
467
+ if (opts.callback_url) body.callback_url = opts.callback_url
468
+ const r = await this.request('videos', {
469
+ method: 'POST',
470
+ headers: { 'Content-Type': 'application/json' },
471
+ body: JSON.stringify(body),
472
+ })
473
+ return { jobId: r.job_id || r.id, upstreamTaskId: r.id }
474
+ }
475
+
476
+ /** 轮询视频任务直到完成/失败。返回 { cdn_url, status } */
477
+ async waitVideo(
478
+ jobId: string,
479
+ onProgress?: (status: string) => void,
480
+ intervalMs = 5000,
481
+ maxAttempts = 120, // 视频通常 1-5 分钟,最多等 10 分钟
482
+ ): Promise<{ cdn_url: string | null; status: string; error?: string }> {
483
+ for (let i = 0; i < maxAttempts; i++) {
484
+ await sleep(intervalMs)
485
+ const r = await this.request(`videos?id=${encodeURIComponent(jobId)}`)
486
+ onProgress?.(r.status || 'processing')
487
+ if (r.status === 'completed') return { cdn_url: r.cdn_url || null, status: 'completed' }
488
+ if (r.status === 'failed') return { cdn_url: null, status: 'failed', error: r.error || '未知原因' }
489
+ }
490
+ throw new Error(`视频生成超时(${(maxAttempts * intervalMs) / 1000}s 未完成,jobId: ${jobId})`)
491
+ }
492
+
493
+ /**
494
+ * 图片逆向(**纯读图**):传文件路径或图片 URL,拿回 SCULPT 六要素与出图 prompt。
495
+ *
496
+ * ⚠️ 这里只发图片,一个别的字段都不发。2026-08-16 中台把「读图」和「把图做成模板」
497
+ * 拆成两个接口后,/api/reverse 见到 variablize / variables / variable_labels /
498
+ * create_template / template / async 任何一个都会直接 400(不是静默忽略)。
499
+ * 要做模板走 imageToTemplate()。
500
+ */
501
+ async reverse(input: { file?: string; imageUrl?: string }): Promise<ReverseResult> {
502
+ if (input.file) {
503
+ return this.request('reverse', { method: 'POST', body: fileForm(input.file) })
504
+ }
505
+ return this.request('reverse', {
506
+ method: 'POST',
507
+ headers: { 'Content-Type': 'application/json' },
508
+ body: JSON.stringify({ image_url: input.imageUrl }),
509
+ })
510
+ }
511
+
512
+ /**
513
+ * 上传素材(POST /api/upload-ref),返回公网直链。
514
+ *
515
+ * 图片 / 音频 / 视频都收:中台按**字节魔数**判真实类型(不信客户端声明的 MIME),
516
+ * 分类型限大小——图片 8MB / 音频 20MB / 视频 50MB。认不出类型直接 400。
517
+ * 同一归属每小时 120 个的防滥用刹车在服务端,超了返回 429。
518
+ */
519
+ async uploadRef(filePath: string): Promise<{ url: string; media_type?: string; mime?: string }> {
520
+ const r = await this.request('upload-ref', { method: 'POST', body: fileForm(filePath) })
521
+ return { url: r.url, media_type: r.media_type, mime: r.mime }
522
+ }
523
+
524
+ /**
525
+ * 图片转模板(POST /api/image-to-template):一张图 → 一个可复用的图片模板。
526
+ *
527
+ * 同步还是异步**由入参决定,不要猜**(中台契约 §7.6):
528
+ * createTemplate=true 或 async=true → 返回 { jobId }(这里是 ImageToTemplateJob)
529
+ * 两者都不给 → 直接返回结果本体(ImageToTemplateResult)
530
+ * 调用方用返回值里有没有 jobId 区分,见 isAsyncJob()。
531
+ *
532
+ * 这个接口没有 variablize 开关:调它本身就是「我要模板」这个意图。
533
+ */
534
+ async imageToTemplate(input: ImageToTemplateInput): Promise<ImageToTemplateResult | ImageToTemplateJob> {
535
+ const { file, imageUrl, variables, variableLabels, createTemplate, template, async: forceAsync } = input
536
+ if (file) {
537
+ // multipart 分支:中台 formOptions() 对这几个键做 JSON.parse(variables 还支持逗号分隔),
538
+ // 所以对象/数组要自己序列化成字符串,不能直接塞进 FormData。
539
+ const fd = fileForm(file)
540
+ if (variables?.length) fd.append('variables', JSON.stringify(variables))
541
+ if (variableLabels) fd.append('variable_labels', JSON.stringify(variableLabels))
542
+ if (createTemplate) fd.append('create_template', 'true')
543
+ if (template) fd.append('template', JSON.stringify(template))
544
+ if (forceAsync) fd.append('async', 'true')
545
+ return this.request('image-to-template', { method: 'POST', body: fd })
546
+ }
547
+ const body: Record<string, unknown> = { image_url: imageUrl }
548
+ if (variables?.length) body.variables = variables
549
+ if (variableLabels) body.variable_labels = variableLabels
550
+ if (createTemplate) body.create_template = true
551
+ if (template) body.template = template
552
+ if (forceAsync) body.async = true
553
+ return this.request('image-to-template', {
554
+ method: 'POST',
555
+ headers: { 'Content-Type': 'application/json' },
556
+ body: JSON.stringify(body),
557
+ })
558
+ }
559
+
560
+ /** 取 image-to-template 异步任务的**结果本体**(进度看 getJob 的 steps,结果在这里) */
561
+ async getImageToTemplateResult(jobId: string): Promise<ImageToTemplateJobResult> {
562
+ return this.request(`image-to-template?job_id=${encodeURIComponent(jobId)}`)
563
+ }
564
+
565
+ /**
566
+ * 轮询 image-to-template 异步任务直到终态,返回结果本体。
567
+ *
568
+ * 进度和结果是两条通道(中台刻意分开的):阶段在 GET /api/jobs?id= 的 steps 里,
569
+ * 结果在 GET /api/image-to-template?job_id= 里。所以这里每轮先拉 job 看阶段
570
+ * (回调给 CLI 打「正在解析图片…」),到终态再去取结果。
571
+ */
572
+ async waitImageToTemplate(
573
+ jobId: string,
574
+ onStep?: (steps: JobStep[]) => void,
575
+ intervalMs = 3000,
576
+ maxAttempts = 100,
577
+ ): Promise<ImageToTemplateResult> {
578
+ for (let i = 0; i < maxAttempts; i++) {
579
+ await sleep(intervalMs)
580
+ const job = await this.getJob(jobId)
581
+ if (job.steps?.length) onStep?.(job.steps)
582
+ if (job.status !== 'done' && job.status !== 'failed') continue
583
+
584
+ const out = await this.getImageToTemplateResult(jobId)
585
+ if (job.status === 'failed' || out.status === 'failed') {
586
+ throw new Error(`图生模板失败: ${out.error || job.error || '未知原因'}(jobId: ${jobId})`)
587
+ }
588
+ if (!out.result) {
589
+ // 任务是 done 但结果取不到——中台把原因写在 error 里(例如结果列缺失还没跑迁移)。
590
+ // 不再继续轮询:状态已经是终态,等下去也不会变。
591
+ throw new Error(`任务已完成但取不到结果:${out.error || '中台未返回 result'}(jobId: ${jobId})`)
592
+ }
593
+ return out.result
594
+ }
595
+ throw new Error(`图生模板超时(${(maxAttempts * intervalMs) / 1000}s 未完成,jobId: ${jobId})`)
596
+ }
597
+
598
+ /** 查当前登录账户信息(含租户归属品牌)。仅个人 token 鉴权可用,apiKey 调用会 401。 */
599
+ async me(): Promise<MeInfo> {
600
+ return this.request('me')
601
+ }
602
+
603
+ /** 查可用模型列表 */
604
+ async models(): Promise<ModelOption[]> {
605
+ return this.request('available-models')
606
+ }
607
+
608
+ /** 查上游余额 */
609
+ async balance(): Promise<Balance> {
610
+ return this.request('balance')
611
+ }
612
+ }
613
+
614
+ function sleep(ms: number): Promise<void> {
615
+ return new Promise((r) => setTimeout(r, ms))
616
+ }
617
+
618
+ /**
619
+ * 把本地文件包成 multipart 的 file 字段。
620
+ *
621
+ * 带上原文件名:中台判类型靠字节魔数不靠这个,但文件名会进日志/对象存储的排查线索,
622
+ * 匿名的 "blob" 出问题时谁也认不出是哪张图。故意不设 MIME——声明的 MIME 中台本来就不信。
623
+ */
624
+ function fileForm(filePath: string): FormData {
625
+ const fd = new FormData()
626
+ fd.append('file', new Blob([readFileSync(filePath)]), basename(filePath))
627
+ return fd
628
+ }
629
+
630
+ /**
631
+ * 区分 imageToTemplate() 拿到的是异步回执还是结果本体。
632
+ * 判据是有没有 jobId —— 跟中台契约一致,不靠 status 字符串猜。
633
+ */
634
+ export function isAsyncJob(r: ImageToTemplateResult | ImageToTemplateJob): r is ImageToTemplateJob {
635
+ return typeof (r as ImageToTemplateJob).jobId === 'string'
636
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * museav assets —— 查所属租户自己的素材/资产库。
3
+ *
4
+ * 数据不在 Studio 中台,在租户自己的后台(见 ../tenant-client.ts 顶部注释)。
5
+ *
6
+ * 【响应形状故意没有统一】两个已接入租户的"素材"根本不是一回事:
7
+ * - 好易美(hym):明星素材库 + 贴图库两张互不关联的表 → 返回
8
+ * { celebrity_materials: [...], stickers: [...] }
9
+ * - mzmeso:品牌素材库 brand_assets 一张表 → 返回扁平数组 [...]
10
+ * 调查过程中发现二者字段/数量级差异很大,硬凑成一种格式只会两边都不像,所以这里
11
+ * 按响应形状分别展示,而不是假装它们是同一种资源。
12
+ */
13
+ import type { TenantClient } from '../tenant-client.js'
14
+
15
+ export async function assets(client: TenantClient): Promise<void> {
16
+ const data = await client.get<unknown>('tenant-assets')
17
+
18
+ if (Array.isArray(data)) {
19
+ if (!data.length) {
20
+ process.stderr.write('该租户暂无素材数据\n')
21
+ console.log('[]')
22
+ return
23
+ }
24
+ process.stderr.write(`素材(${data.length} 个):\n`)
25
+ for (const a of data as Array<Record<string, unknown>>) {
26
+ process.stderr.write(` ${String(a.id).padEnd(38)} ${String(a.category || '').padEnd(14)} ${String(a.name || '')}\n`)
27
+ }
28
+ console.log(JSON.stringify(data))
29
+ return
30
+ }
31
+
32
+ if (data && typeof data === 'object') {
33
+ const obj = data as Record<string, unknown[]>
34
+ for (const [group, rows] of Object.entries(obj)) {
35
+ const list = Array.isArray(rows) ? rows : []
36
+ process.stderr.write(`${group}(${list.length} 个):\n`)
37
+ for (const r of list as Array<Record<string, unknown>>) {
38
+ process.stderr.write(` ${String(r.id).padEnd(38)} ${String(r.name || r.artist || '')}\n`)
39
+ }
40
+ }
41
+ console.log(JSON.stringify(obj))
42
+ return
43
+ }
44
+
45
+ process.stderr.write('返回数据格式未知,原样输出到 stdout\n')
46
+ console.log(JSON.stringify(data))
47
+ }