@uzooai/anime-ai-cli 0.0.0-stage → 0.5.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 (123) hide show
  1. package/.agents/skills/uzoo-anime-cli/SKILL.md +83 -0
  2. package/.agents/skills/uzoo-anime-cli/references/agreement-workflow.md +51 -0
  3. package/.agents/skills/uzoo-anime-cli/references/canvas-workflow.md +53 -0
  4. package/.agents/skills/uzoo-anime-cli/references/commands.md +250 -0
  5. package/.agents/skills/uzoo-anime-cli/references/generation-workflow.md +152 -0
  6. package/.agents/skills/uzoo-anime-cli/references/image-tools-workflow.md +175 -0
  7. package/.agents/skills/uzoo-anime-cli/references/interaction-workflow.md +67 -0
  8. package/.agents/skills/uzoo-anime-cli/references/material-graph-workflow.md +123 -0
  9. package/.agents/skills/uzoo-anime-cli/references/node-capabilities.md +95 -0
  10. package/.agents/skills/uzoo-anime-cli/references/node-workflow.md +94 -0
  11. package/.agents/skills/uzoo-anime-cli/references/remake-workflow.md +54 -0
  12. package/.agents/skills/uzoo-anime-cli/references/repaint-workflow.md +300 -0
  13. package/.agents/skills/uzoo-anime-cli/references/seedance-review-workflow.md +41 -0
  14. package/.agents/skills/uzoo-anime-cli/references/video-tools-workflow.md +71 -0
  15. package/CHANGELOG.md +92 -0
  16. package/LICENSE +21 -0
  17. package/README.md +242 -3
  18. package/assets/README.md +4 -0
  19. package/assets/favicon-uzoo.png +0 -0
  20. package/assets/logo-uzoo.svg +7 -0
  21. package/docs/CLI/345/275/223/345/211/215/350/214/203/345/233/264/344/270/216/346/234/254/345/234/260/346/211/223/345/214/205-20261103.md +134 -0
  22. package/docs/CLI/346/216/210/346/235/203/346/216/245/345/217/243/344/270/216/344/273/244/347/211/214/350/207/252/345/212/250/345/210/267/346/226/260/350/256/276/350/256/241.md +540 -0
  23. package/docs/CLI/347/274/272/345/217/243/346/240/270/345/257/271/344/270/216/346/224/266/345/260/276-20261105.md +65 -0
  24. package/docs/CLI/347/275/221/351/241/265/344/277/235/345/255/230/350/212/202/347/202/271/347/274/272/345/244/261/344/272/244/346/216/245.md +52 -0
  25. package/docs/M0-M5/345/256/236/346/226/275/344/270/216/351/252/214/346/224/266.md +89 -0
  26. package/docs/npm/347/272/277/344/270/212/345/217/221/345/270/203/344/270/216/345/256/211/350/243/205/344/275/277/347/224/250.md +151 -0
  27. package/docs//344/270/213/350/275/275/346/216/245/345/217/243/351/200/202/351/205/215/344/270/216/351/252/214/346/224/266.md +107 -0
  28. package/docs//344/270/232/345/212/241/350/203/275/345/212/233/350/241/245/345/205/250/344/270/216Skill/347/273/223/346/236/204/350/247/204/345/210/222.md +311 -0
  29. package/docs//345/205/250/345/261/200/351/231/204/344/273/266/350/265/204/346/272/220/345/234/260/345/235/200/344/270/216/346/250/241/345/236/213/350/257/273/345/217/226/345/267/256/345/274/202.md +47 -0
  30. package/docs//345/206/205/351/203/250Windows/345/256/211/350/243/205/344/270/216/345/215/207/347/272/247/351/252/214/346/224/266/346/270/205/345/215/225.md +34 -0
  31. package/docs//345/233/276/347/211/207/346/212/240/345/233/276/346/216/245/345/205/245/344/270/216/351/252/214/346/224/266-20261104.md +110 -0
  32. package/docs//345/274/200/345/217/221/350/256/241/345/210/222.md +127 -0
  33. package/docs//345/274/202/346/255/245/346/212/240/345/233/276CLI/346/235/203/351/231/220/344/272/244/346/216/245-20261105.md +50 -0
  34. package/docs//345/274/202/346/255/245/346/212/240/345/233/276/347/234/237/345/256/236/351/252/214/346/224/266-20261105.md +44 -0
  35. package/docs//346/226/260/344/274/232/350/257/235/344/273/273/345/212/241/344/272/244/344/273/243-/351/230/266/346/256/2652A.md +79 -0
  36. package/docs//346/226/260/351/203/250/347/275/262/347/224/273/345/270/203/345/206/262/347/252/201/351/252/214/346/224/266-20261102.md +61 -0
  37. package/docs//346/231/256/351/200/232/346/265/217/350/247/210/345/231/250/347/231/273/345/275/225.md +53 -0
  38. package/docs//346/255/243/345/274/217/347/253/231/345/210/207/346/215/242/344/270/216/351/252/214/346/224/266-20261104.md +90 -0
  39. package/docs//347/224/273/345/270/203CLI/344/270/213/344/270/200/351/230/266/346/256/265/345/274/200/345/217/221/350/256/241/345/210/222.md +136 -0
  40. package/docs//347/224/273/345/270/203CLI/351/235/236/350/275/254/347/273/230/345/256/214/345/226/204/350/256/241/345/210/222.md +183 -0
  41. package/docs//347/224/273/345/270/203/350/203/275/345/212/233/345/245/221/347/272/246/346/240/270/345/257/271.md +59 -0
  42. package/docs//347/234/237/345/256/236/344/270/232/345/212/241/346/216/245/345/205/245/344/270/216/346/265/213/350/257/225.md +1004 -0
  43. package/docs//347/264/240/346/235/220/345/205/245/345/272/223/344/270/216/347/224/273/345/270/203/344/277/235/345/255/230/351/227/256/351/242/230/345/256/232/344/275/215.md +52 -0
  44. package/docs//351/230/266/346/256/2653B/351/252/214/346/224/266/344/272/244/346/216/245.md +44 -0
  45. package/docs//351/230/266/346/256/265/344/270/200/351/252/214/346/224/266.md +60 -0
  46. package/docs//351/235/236/350/275/254/347/273/230/346/214/201/347/273/255/345/256/214/345/226/204/344/270/216/351/252/214/346/224/266-20261103.md +76 -0
  47. package/docs//351/235/236/350/275/254/347/273/230/347/254/254/344/272/214/346/211/271/345/256/236/346/226/275/344/270/216/351/252/214/346/224/266.md +39 -0
  48. package/docs//351/235/236/350/275/254/347/273/230/350/241/245/345/205/205/346/235/241/344/273/266/351/252/214/346/224/266-20261103.md +113 -0
  49. package/docs//351/235/236/350/275/254/347/273/230/351/246/226/346/211/271/345/256/236/346/226/275/344/270/216/351/252/214/346/224/266.md +71 -0
  50. package/examples/matting.json +5 -0
  51. package/examples/real-business/image-request.template.json +7 -0
  52. package/examples/real-business/node-patch.template.json +11 -0
  53. package/extensions/uzoo-cli-login/manifest.json +9 -0
  54. package/extensions/uzoo-cli-login/popup.css +1 -0
  55. package/extensions/uzoo-cli-login/popup.html +14 -0
  56. package/extensions/uzoo-cli-login/popup.js +65 -0
  57. package/package.json +56 -6
  58. package/scripts/check-package-resources.mjs +56 -0
  59. package/scripts/check-release.mjs +19 -0
  60. package/scripts/pack-local.mjs +33 -0
  61. package/scripts/release-policy.mjs +22 -0
  62. package/src/agreement-page.js +62 -0
  63. package/src/agreement.js +419 -0
  64. package/src/annotation-media.js +386 -0
  65. package/src/api.js +168 -0
  66. package/src/browser-login.js +13 -0
  67. package/src/callback-page.js +44 -0
  68. package/src/canvas.js +105 -0
  69. package/src/cli-authorization.js +122 -0
  70. package/src/cli.js +459 -0
  71. package/src/download-name.js +17 -0
  72. package/src/draft-finalize.js +229 -0
  73. package/src/generation-graph.js +124 -0
  74. package/src/generation-input.js +472 -0
  75. package/src/graph-disconnect.js +89 -0
  76. package/src/graph.js +431 -0
  77. package/src/group-edit.js +53 -0
  78. package/src/group-layout.js +113 -0
  79. package/src/group-membership.js +126 -0
  80. package/src/history-add.js +119 -0
  81. package/src/image-annotation.js +246 -0
  82. package/src/image-crop.js +283 -0
  83. package/src/image-matting.js +82 -0
  84. package/src/image-media.js +89 -0
  85. package/src/image-output.js +385 -0
  86. package/src/local-output.js +23 -0
  87. package/src/material-download.js +55 -0
  88. package/src/material-save.js +254 -0
  89. package/src/materials.js +147 -0
  90. package/src/matting-background.js +73 -0
  91. package/src/matting-jobs.js +63 -0
  92. package/src/matting-worker.js +49 -0
  93. package/src/matting.js +170 -0
  94. package/src/media-copy.js +53 -0
  95. package/src/media.js +343 -0
  96. package/src/model-options.js +76 -0
  97. package/src/multipart.js +28 -0
  98. package/src/node-copy.js +95 -0
  99. package/src/node-delete.js +49 -0
  100. package/src/node-history.js +231 -0
  101. package/src/normal-login.js +118 -0
  102. package/src/package-info.js +8 -0
  103. package/src/png-alpha.js +60 -0
  104. package/src/protocol.js +83 -0
  105. package/src/remake.js +738 -0
  106. package/src/repaint-archive.js +53 -0
  107. package/src/repaint-delivery.js +783 -0
  108. package/src/repaint-export.js +63 -0
  109. package/src/repaint-graph.js +214 -0
  110. package/src/repaint-history.js +267 -0
  111. package/src/repaint-materials.js +601 -0
  112. package/src/repaint-reverse.js +614 -0
  113. package/src/repaint-sequence.js +132 -0
  114. package/src/repaint-settings.js +576 -0
  115. package/src/repaint-video.js +798 -0
  116. package/src/repaint.js +326 -0
  117. package/src/resource.js +110 -0
  118. package/src/review.js +435 -0
  119. package/src/service.js +85 -0
  120. package/src/store.js +100 -0
  121. package/src/video-clip.js +381 -0
  122. package/src/video-frame.js +296 -0
  123. package/src/workflows.js +214 -0
package/src/matting.js ADDED
@@ -0,0 +1,170 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import {isDeepStrictEqual as same} from 'node:util'
5
+ import {root,safeName,json,atomic} from './store.js'
6
+ import {mattingInput} from './image-matting.js'
7
+ import {readImageSource} from './image-crop.js'
8
+ import {requireServiceReady} from './service.js'
9
+ import {multipartText} from './multipart.js'
10
+ import {materialScope} from './materials.js'
11
+ import {requestFile} from './api.js'
12
+ import {inspectAlphaPng} from './png-alpha.js'
13
+
14
+ export const mattingPath=(profile,operation)=>path.join(root(),'image-matting',safeName(profile),safeName(operation),'operation.json')
15
+ const persist=r=>atomic(mattingPath(r.profile,r.operation),r)
16
+ const fail=(code,message,cause)=>Object.assign(Error(message,{cause}),{code})
17
+ const summary=r=>({operation:r.operation,state:r.state,doc_id:r.input.doc_id,source_node_id:r.input.node_id,
18
+ result_node_id:r.nodeId,source_task_id:r.binding.task_id,processing_submitted:!!r.attempted,generation_submitted:false,library_saved:false,
19
+ canvas_written:false,canvas_verified:r.state==='completed',credit_cost:r.cost??null,output:r.output||null,
20
+ ...(r.image?{image:r.image}:{}),...(r.transport?{transport:r.transport,job_id:r.jobId||null,job_status:r.jobStatus||null,reported_credit_cost:r.reportedCreditCost??null}:{}),recovery:{known_result_reference:!!r.receipt?.['x-result-ref'],unknown_submission_replay:false,
21
+ server_lookup_available:r.transport==='async-job'&&!!r.jobId},...(r.diagnostic?{diagnostic:r.diagnostic}:{})})
22
+ async function context(client,input){
23
+ await requireServiceReady(client)
24
+ const c=await readImageSource(client,input),me=await client.raw('/auth/me')
25
+ if(!me.uid)throw Error('抠图账号缺少UID')
26
+ const team=input.space==='team'?me.teams?.find(t=>String(t.uid)===input.team_uid&&t.status==='active'):null
27
+ if(input.space==='team'&&!team)throw Error('抠图目标团队成员身份无效')
28
+ return {...c,owner:String(me.uid),wallet:{scope:input.space,...(team?{team_uid:input.team_uid,team_name:team.name,shared:true}:{}),
29
+ balance_snapshot:(team||me).oo_balance??(team||me).credit_balance??null,currency:'OO'}}
30
+ }
31
+ async function bound(client,profile,operation){
32
+ const r=await json(mattingPath(profile,operation),null)
33
+ if(!r||r.schema!==1||r.profile!==profile||r.operation!==operation||r.baseUrl!==client.baseUrl)throw Error('抠图operation不存在或服务不匹配')
34
+ const c=await context(client,mattingInput(r.input))
35
+ if(c.owner!==r.owner)throw Error('抠图账号不匹配')
36
+ if(!same(c.node,r.sourceNode)||String(c.binding.task_id)!==String(r.binding.task_id)||c.binding.width!==r.binding.width||c.binding.height!==r.binding.height)
37
+ throw Error('原图片节点/任务已变化;保留原产物,不覆盖或重发抠图')
38
+ if(r.output!==path.join(path.dirname(mattingPath(profile,operation)),'response.png'))throw Error('抠图缓存路径不匹配')
39
+ return {r,c}
40
+ }
41
+ export async function validateMattingBackground(client,profile,operation){
42
+ const {r,c}=await bound(client,profile,operation)
43
+ if(r.attempted)throw Error('原抠图已尝试提交;仅查询/恢复,不启动新后台请求')
44
+ if(r.state!=='prepared'||r.version!==c.doc.version)throw Error('后台执行须使用仍匹配当前画布版本的预览')
45
+ return r
46
+ }
47
+ export function mattingResultNode(r){
48
+ const ref=r.receipt['x-result-ref'],url=new URL('/api/v1/files/'+encodeURIComponent(ref),r.baseUrl).href
49
+ return {id:r.nodeId,type:'image_upload',position:{x:Math.round(Number(r.sourceNode.position?.x||0)+Number(r.sourceNode.width||486)+80),y:Math.round(Number(r.sourceNode.position?.y||0))},
50
+ width:486,height:300,data:{title:(r.sourceNode.data.title||'图片')+'(抠图)',mediaType:'image',url,file_name:'matting.png',file_size:r.image.bytes,
51
+ aspect_ratio:r.image.width+':'+r.image.height,status:'completed',last_task_id:null,
52
+ result:{url,file_ref:ref,mime_type:'image/png',width:r.image.width,height:r.image.height,status:'completed'},
53
+ extra:{node_kind:'asset',file_ref:ref,matting:{status:'completed',progress:100,error:'',creditCost:String(r.cost)}}}}
54
+ }
55
+ export function mattingResultMatches(r,doc){
56
+ const node=doc.nodes.find(n=>n.id===r.nodeId),edge=doc.edges.find(e=>e.id===r.edgeId),d=node?.data||{}
57
+ if(!node||!edge||node.type!=='image_upload'||d.extra?.node_kind!=='asset'||d.mediaType!=='image'||
58
+ d.extra?.file_ref!==r.receipt?.['x-result-ref']||d.file_name!==r.pendingNode?.data.file_name||d.file_size!==r.image?.bytes||
59
+ d.title!==r.pendingNode.data.title||(d.last_task_id||d.lastTaskId||d.runtime?.taskId)||
60
+ edge.source!==r.input.node_id||edge.target!==r.nodeId||edge.data?.role!=='matting-link')return false
61
+ try{
62
+ const wanted=new URL('/api/v1/files/'+encodeURIComponent(r.receipt['x-result-ref']),r.baseUrl)
63
+ const actual=new URL(d.url||d.result?.url,r.baseUrl)
64
+ return actual.origin===wanted.origin&&actual.pathname===wanted.pathname&&!actual.username&&!actual.password
65
+ }catch{return false}
66
+ }
67
+ const matches=(r,c)=>mattingResultMatches(r,c.doc)
68
+ async function saveResult(client,r,c,confirmed){
69
+ if(r.saveAttempted){
70
+ const verified=matches(r,c)
71
+ if(verified){r.state='completed';await persist(r)}
72
+ return {...summary(r),canvas_verified:verified,state:!verified&&r.state==='completed'?'canvas_changed':r.state,version:c.doc.version,
73
+ canvas_exactly_matches:same(c.doc.nodes.find(n=>n.id===r.nodeId),r.pendingNode)&&same(c.doc.edges.find(e=>e.id===r.edgeId),r.pendingEdge)}
74
+ }
75
+ if(!confirmed)return summary(r)
76
+ if(c.doc.nodes.some(n=>n.id===r.nodeId)||c.doc.edges.some(e=>e.id===r.edgeId))throw Error('抠图结果ID已存在,不覆盖')
77
+ if(!Number.isSafeInteger(c.doc.version)||!c.doc.viewport||!c.doc.meta||!Array.isArray(c.doc.edges)||new Set(c.doc.nodes.map(n=>n.id)).size!==c.doc.nodes.length)throw Error('抠图画布快照无效')
78
+ r.pendingNode=mattingResultNode(r)
79
+ r.pendingEdge={id:r.edgeId,source:r.input.node_id,target:r.nodeId,sourceHandle:'',targetHandle:'',type:'default',data:{role:'matting-link',system:true}}
80
+ r.beforeSave=c.doc;r.saveAttempted=true;r.state='saving';await persist(r)
81
+ try{
82
+ const d=c.doc,saved=await client.raw('/canvas/docs/'+encodeURIComponent(d.id),{method:'PUT',body:{version:d.version,name:d.name,viewport:d.viewport,
83
+ nodes:[...d.nodes,r.pendingNode],edges:[...d.edges,r.pendingEdge],meta:d.meta}},{retry:false})
84
+ if(saved.version!==d.version+1)throw Error('抠图保存响应版本无效')
85
+ const after=await context(client,r.input)
86
+ if(!matches(r,after)||!same(after.doc.nodes.filter(n=>n.id!==r.nodeId),d.nodes)||!same(after.doc.edges.filter(e=>e.id!==r.edgeId),d.edges)||
87
+ !['name','viewport','meta'].every(k=>same(after.doc[k],d[k])))throw Error('抠图保存读回不一致')
88
+ r.state='completed';await persist(r)
89
+ return {...summary(r),canvas_written:true,version:after.doc.version}
90
+ }catch(error){r.state=error.status===409||error.apiCode===3002?'conflict':'save_unknown';await persist(r);throw fail('MATTING_SYNC_PENDING','抠图结果保存未确认;原matting-recover只读核对,不重放保存',error)}
91
+ }
92
+ async function validateCached(r){
93
+ const bytes=await fs.readFile(r.output),image=inspectAlphaPng(bytes)
94
+ if(image.width!==r.binding.width||image.height!==r.binding.height)throw Error('抠图结果尺寸与原图不同;保留结果,不重发')
95
+ if(r.image&&!same(r.image,image))throw Error('抠图缓存摘要或透明像素统计变化')
96
+ r.image=image;r.state='result_ready';await persist(r)
97
+ }
98
+ export async function startMatting(client,profile,value,operation,confirmed=false,{backgroundId,deferCanvas=false}={}){
99
+ const input=mattingInput(value),old=await json(mattingPath(profile,operation),null)
100
+ if(old){
101
+ if(!same(old.input,input))throw Error('原抠图operation输入不可更改')
102
+ if(old.backgroundId&&backgroundId!==old.backgroundId&&!old.attempted)throw fail('MATTING_BACKGROUND_ACTIVE','原operation已交给后台;使用matting-status查询,不并行提交')
103
+ if(old.attempted)return recoverMatting(client,profile,operation,confirmed)
104
+ }
105
+ const c=await context(client,input)
106
+ if(old&&(old.baseUrl!==client.baseUrl||old.owner!==c.owner))throw Error('抠图operation已绑定其他服务或账号')
107
+ if(!confirmed){
108
+ const r={schema:1,profile,operation,baseUrl:client.baseUrl,owner:c.owner,input,sourceNode:c.node,binding:c.binding,version:c.doc.version,
109
+ nodeId:old?.nodeId||'matting-'+crypto.randomUUID(),edgeId:old?.edgeId||'matting-link-'+crypto.randomUUID(),
110
+ output:path.join(path.dirname(mattingPath(profile,operation)),'response.png'),state:'prepared',createdAt:old?.createdAt||new Date().toISOString()}
111
+ await persist(r)
112
+ return {...summary(r),source:c.source,wallet:c.wallet,expected_credit_cost:0,pricing_policy:{mode:'free',basis:'user-confirmed product contract',response_verified:false},
113
+ expected_version:c.doc.version,next:'确认免费处理及目标画布关闭后,同operation加--yes;未知响应绝不重发'}
114
+ }
115
+ if(!old)throw Error('抠图须先预览同一operation')
116
+ const {r,c:current}=await bound(client,profile,operation)
117
+ if(current.doc.version!==r.version)throw Error('预览后画布版本变化,先重新预览')
118
+ const form=multipartText({file_ref:current.binding.file_ref,model:'general',...materialScope(input.space,input.team_uid)})
119
+ r.attempted=true;r.state='submitting';await persist(r)
120
+ let response
121
+ try{response=await client.raw('/matting',{method:'POST',...form},{retry:false,binaryPng:true})}
122
+ catch(error){
123
+ r.state=[2001,2010,2011,2012,2205].includes(error.apiCode)||[400,401,403,404,422].includes(error.status)?'rejected':'submission_unknown'
124
+ r.diagnostic={...(error.status?{http_status:error.status}:{}),...(error.apiCode?{api_code:error.apiCode}:{}),...(error.requestId?{request_id:error.requestId}:{})}
125
+ await persist(r);throw fail('MATTING_SUBMISSION_PENDING','抠图未取得原结果;保留operation,不重发推理。明确拒绝/未知响应分别见state',error)
126
+ }
127
+ r.receipt=response.headers;r.state='response_received';await persist(r)
128
+ // Cache the original bytes before validating any server metadata, including cost.
129
+ await fs.writeFile(r.output,response.body,{flag:'wx',mode:0o600})
130
+ const ref=r.receipt['x-result-ref'],source=r.receipt['x-source-ref'],cost=r.receipt['x-credit-cost']
131
+ if(!/^fr1_[A-Za-z0-9_-]+={0,2}$/.test(ref||'')||source!==current.binding.file_ref||cost!=='0'){
132
+ r.state='receipt_unverified';await persist(r);throw Error('抠图结果引用/源引用/免费费用响应不匹配;已保留原字节,不自动落图或重发')
133
+ }
134
+ r.cost=0
135
+ try{await validateCached(r)}catch(error){r.state='result_unverified';await persist(r);throw error}
136
+ if(deferCanvas)return {...summary(r),next:'后台已缓存原结果;确认画布关闭后,用原matting-recover --yes落图,不重做抠图'}
137
+ const checked=await bound(client,profile,operation)
138
+ return saveResult(client,r,checked.c,true)
139
+ }
140
+ export async function recoverMatting(client,profile,operation,confirmed=false){
141
+ const stored=await json(mattingPath(profile,operation),null)
142
+ if(stored?.transport==='async-job'){
143
+ const {recoverMattingJob}=await import('./matting-jobs.js')
144
+ return recoverMattingJob(client,profile,operation,confirmed)
145
+ }
146
+ const {r,c}=await bound(client,profile,operation)
147
+ if(r.saveAttempted)return saveResult(client,r,c,false)
148
+ if(['prepared','submitting','submission_unknown','rejected','receipt_unverified','result_unverified'].includes(r.state))return summary(r)
149
+ if(!r.receipt?.['x-result-ref']||r.receipt['x-credit-cost']!=='0'||r.receipt['x-source-ref']!==r.binding.file_ref)return summary(r)
150
+ r.cost=0
151
+ await cacheMattingReference(client,r,confirmed)
152
+ await validateCached(r)
153
+ return saveResult(client,r,c,confirmed)
154
+ }
155
+ async function cacheMattingReference(client,r,confirmed){
156
+ try{await fs.access(r.output)}catch(error){
157
+ if(error.code!=='ENOENT'||!confirmed)throw error
158
+ const response=await requestFile(new URL('/api/v1/files/'+encodeURIComponent(r.receipt['x-result-ref']),client.baseUrl),client.timeout)
159
+ if(!response.ok||!/^image\/png(?:;|$)/i.test(response.headers.get('content-type')||'')) {await response.body?.cancel();throw Error('原抠图结果GET不可用;不重新处理')}
160
+ const chunks=[];let size=0
161
+ for await(const chunk of response.body){size+=chunk.length;if(size>128*1024*1024)throw Error('原结果超过128MB');chunks.push(chunk)}
162
+ await fs.writeFile(r.output,Buffer.concat(chunks,size),{flag:'wx',mode:0o600})
163
+ }
164
+ }
165
+
166
+ // Shared verified-result pipeline for transport adapters. Transport-specific
167
+ // submission and task lookup must not bypass account/source binding, PNG
168
+ // validation, or the single-attempt canvas save contract.
169
+ export {bound as readBoundMatting,summary as mattingSummary,persist as persistMatting,
170
+ validateCached as validateMattingCache,saveResult as saveMattingResult,cacheMattingReference}
@@ -0,0 +1,53 @@
1
+ // Persistent canvas protocol counterpart of clipboard.js and copiedNodeHistory.js.
2
+ // Source task IDs in copied history are provenance, never the copy's running task.
3
+ const text = value => String(value ?? '').trim()
4
+ export function captureMediaHistory(node) {
5
+ const d=node.data, e=d.extra||{}, r=d.result||{}
6
+ const history=structuredClone(d.copiedGenerationHistory ?? e.copied_generation_history ?? [])
7
+ if(!Array.isArray(history))throw Error('复制来源历史格式无效')
8
+ const url=text(d.resultUrl||r.url)
9
+ if(!url||history.some(h=>h.url===url))return history
10
+ const task=text(d.lastTaskId||d.last_task_id||d.runtime?.taskId||d.sourceTaskId||e.source_task_id)
11
+ const modes={text2image:'text-to-image',image2image:'image-to-image',text_to_video:'text-to-video',image_reference:'image-reference',video_reference:'video-reference',audio_reference:'audio-reference',combined_reference:'combined-reference',video_editing:'video-editing',video_extension:'video-extension'}
12
+ const mode=d.imageMode||d.videoMode||modes[e.generation_mode]||(node.type==='image'?'text-to-image':'text-to-video')
13
+ const raw={id:task||`copied-${node.id}`,task_no:text(d.taskNo||d.runtime?.taskNo||r.task_no||e.task_no),source:'copied',node_id:node.id,
14
+ status:'completed',progress:100,prompt:d.effectivePrompt||d.prompt||'',model_name:d.model||d.model_name,
15
+ generation_mode:mode,aspect_ratio:d.aspectRatio||e.aspect_ratio||'',
16
+ resolution:d.resolution||e.resolution||'',quality:d.quality||e.quality||null,extra:{image_count:Number(d.imageCount||e.image_count)||0},
17
+ result:{...r,url,file_ref:d.resultFileRef||r.file_ref||e.file_ref||'',width:d.imageWidth||d.videoWidth,height:d.imageHeight||d.videoHeight}}
18
+ // Match the frontend normalized history shape. JSON round trip omits optional
19
+ // absent raw fields exactly as they are omitted by the API serializer.
20
+ const item={id:'copied:'+raw.id,taskId:task,taskNo:raw.task_no,docId:'',nodeId:node.id,segmentType:'',contentType:'',source:'copied',jobId:'',segmentIndex:null,taskVersion:null,
21
+ status:'completed',progress:100,creditCost:0,draft:null,url,fileRef:text(raw.result.file_ref),mimeType:text(r.mime_type||r.mimeType),width:Number(raw.result.width)||null,height:Number(raw.result.height)||null,duration:Number(r.duration)||null,
22
+ text:'',structured:null,structuredRender:null,structuredMeta:null,model:text(raw.model_name||r.model),prompt:text(raw.prompt),referenceImageUrls:[],referenceVideoUrls:[],referenceAudioUrls:[],referenceAssetIds:[],
23
+ mode:text(raw.generation_mode),aspectRatio:text(raw.aspect_ratio),resolution:text(raw.resolution),quality:text(raw.quality),imageCount:Number(raw.extra.image_count)||1,error:'',generatedAt:'',createdAt:'',completedAt:'',raw}
24
+ return JSON.parse(JSON.stringify([item,...history]))
25
+ }
26
+
27
+ export function buildMediaCopy(source) {
28
+ const d=source?.data||{},e=d.extra||{}
29
+ const asset=source?.type?.endsWith('_upload')||e.node_kind==='asset'
30
+ const type=asset?(d.mediaType||source.type.replace('_upload','')):source?.type
31
+ if(!['image','video',...(asset?['audio']:[])].includes(type))throw Error('复制仅支持普通文本、图片/视频生成节点及图片/视频/音频素材')
32
+ if(d.generation_config||d.node_output)throw Error('当前节点配置协议未接入复制')
33
+ const copy=structuredClone(source),data=copy.data
34
+ if(asset){
35
+ // Upload task association identifies the original uploaded file, not a new
36
+ // generation. Keep it for review/library reuse, as the web clipboard does.
37
+ data.extra={...e,node_kind:'asset'}
38
+ return {copy,kind:'asset',source_task_id:text(d.sourceTaskId||e.source_task_id||d.last_task_id||d.lastTaskId||d.runtime?.taskId)}
39
+ }
40
+ const history=captureMediaHistory(source)
41
+ const task=text(d.lastTaskId||d.last_task_id||d.runtime?.taskId||d.result?.task_id||d.sourceTaskId||e.source_task_id)
42
+ const complete=(d.runtime?.status||d.status)==='completed'||Boolean((d.resultUrl||d.result?.url)&&history.length)
43
+ for(const key of ['lastTaskId','last_task_id','taskId','task_id','taskNo','task_no','progress','error_message','errorMessage'])delete data[key]
44
+ data.status=complete?'completed':null
45
+ if(data.runtime)data.runtime={status:complete?'completed':'idle',progress:complete?100:0,taskId:'',taskNo:'',error:''}
46
+ if(data.result&&typeof data.result==='object')for(const key of ['taskId','task_id','taskNo','task_no'])delete data.result[key]
47
+ data.extra={...e}
48
+ delete data.extra.task_no
49
+ if(history.length){data.extra.copied_generation_history=history;data.extra.source_task_id=task;data.extra.node_kind='generation'}
50
+ // Canonical persistent fields avoid divergent camel/snake histories.
51
+ delete data.copiedGenerationHistory;delete data.sourceTaskId
52
+ return {copy,kind:'generation',source_task_id:task,copied_history_count:history.length}
53
+ }
package/src/media.js ADDED
@@ -0,0 +1,343 @@
1
+ import { spawn } from 'node:child_process'
2
+ import fs from 'node:fs/promises'
3
+ import crypto from 'node:crypto'
4
+ import { requestFile, cancellation } from './api.js'
5
+
6
+ const mediaError = (message) => Object.assign(new Error(message), { code: 'MEDIA_UNAVAILABLE' })
7
+ export async function runMedia(
8
+ exe,
9
+ args,
10
+ { timeout = 120000, maxBytes = 16 * 1024 * 1024, cwd } = {}
11
+ ) {
12
+ return new Promise((resolve, reject) => {
13
+ const child = spawn(exe, args, {
14
+ windowsHide: true,
15
+ shell: false,
16
+ ...(cwd ? { cwd } : {}),
17
+ stdio: ['ignore', 'pipe', 'pipe']
18
+ })
19
+ const chunks = []
20
+ let bytes = 0,
21
+ settled = false
22
+ const finish = (error, output) => {
23
+ if (settled) return
24
+ settled = true
25
+ clearTimeout(timer)
26
+ cancellation.signal.removeEventListener('abort', abort)
27
+ error ? reject(error) : resolve(output)
28
+ }
29
+ const abort = () => {
30
+ child.kill()
31
+ finish(mediaError('媒体处理已中断;保留原操作,未提交生成'))
32
+ }
33
+ const timer = setTimeout(() => {
34
+ child.kill()
35
+ finish(mediaError('媒体处理超时;请检查源文件与解码工具'))
36
+ }, timeout)
37
+ cancellation.signal.addEventListener('abort', abort, { once: true })
38
+ if (cancellation.signal.aborted) abort()
39
+ child.stdout.on('data', (chunk) => {
40
+ bytes += chunk.length
41
+ if (bytes > maxBytes) {
42
+ child.kill()
43
+ finish(mediaError('媒体元数据超过可处理范围,停止解析'))
44
+ } else chunks.push(chunk)
45
+ })
46
+ // Do not echo arbitrary media metadata, paths or decoder stderr into CLI logs.
47
+ child.stderr.resume()
48
+ child.on('error', (error) =>
49
+ finish(
50
+ Object.assign(mediaError('无法运行媒体工具;请检查依赖、路径与执行权限'), {
51
+ toolFailure: error.code === 'ENOENT' ? 'missing' : 'unavailable'
52
+ })
53
+ )
54
+ )
55
+ child.on('close', (code) =>
56
+ code === 0
57
+ ? finish(null, Buffer.concat(chunks))
58
+ : finish(mediaError('媒体解码工具执行失败;检查格式、完整性及工具版本,不自动上传'))
59
+ )
60
+ })
61
+ }
62
+ export async function mediaTools(options = {}) {
63
+ const tools = { ffmpeg: options.ffmpeg || 'ffmpeg', ffprobe: options.ffprobe || 'ffprobe' }
64
+ const checks = []
65
+ for (const [kind, executable] of Object.entries(tools)) {
66
+ try {
67
+ const output = (
68
+ await runMedia(executable, ['-version'], { timeout: 10000, maxBytes: 65536 })
69
+ ).toString()
70
+ checks.push({
71
+ tool: kind,
72
+ status: output.startsWith(kind + ' version') ? 'available' : 'invalid_response'
73
+ })
74
+ } catch (error) {
75
+ if (cancellation.signal.aborted) throw error
76
+ checks.push({ tool: kind, status: error.toolFailure || 'unavailable' })
77
+ }
78
+ }
79
+ if (checks.some((check) => check.status !== 'available')) {
80
+ const missing = checks.filter((check) => check.status === 'missing').map((check) => check.tool)
81
+ const installable =
82
+ missing.length > 0 && checks.every((check) => ['available', 'missing'].includes(check.status))
83
+ throw Object.assign(
84
+ mediaError(
85
+ installable
86
+ ? '无法运行媒体工具:未找到 ' +
87
+ missing.join('、') +
88
+ '。本地媒体处理需要 FFmpeg(包含 ffmpeg 和 ffprobe);请先询问用户是否安装,或由用户提供已有工具路径,同意安装后才能执行安装并继续原任务'
89
+ : '媒体工具未通过检查;先核对已有工具路径、执行权限与版本,再决定处理方式'
90
+ ),
91
+ {
92
+ dependency: {
93
+ status: installable ? 'awaiting_dependency_installation' : 'dependency_unavailable',
94
+ package: 'FFmpeg',
95
+ required_tools: ['ffmpeg', 'ffprobe'],
96
+ missing_tools: missing,
97
+ checks,
98
+ installation_requires_confirmation: true,
99
+ installation_performed: false,
100
+ next: installable ? 'ask_user_to_install_or_provide_paths' : 'check_existing_tools'
101
+ }
102
+ }
103
+ )
104
+ }
105
+ return tools
106
+ }
107
+ export async function hashFile(file) {
108
+ const handle = await fs.open(file, 'r')
109
+ try {
110
+ const stat = await handle.stat(),
111
+ hash = crypto.createHash('sha256')
112
+ for await (const chunk of handle.createReadStream({ autoClose: false })) hash.update(chunk)
113
+ return { bytes: stat.size, sha256: hash.digest('hex') }
114
+ } finally {
115
+ await handle.close()
116
+ }
117
+ }
118
+ export async function downloadMedia(client, url, target, type = 'video') {
119
+ const destination = new URL(url, new URL(client.baseUrl).origin)
120
+ const response = await requestFile(destination, client.timeout)
121
+ const length = Number(response.headers.get('content-length')) || 0
122
+ const max = 500 * 1024 * 1024
123
+ if (
124
+ !response.ok ||
125
+ !response.headers
126
+ .get('content-type')
127
+ ?.toLowerCase()
128
+ .startsWith(type + '/') ||
129
+ length > max
130
+ ) {
131
+ await response.body?.cancel()
132
+ throw new Error('源媒体不是可下载的媒体或超过 500MB;未开始处理或上传')
133
+ }
134
+ const handle = await fs.open(target, 'wx', 0o600)
135
+ let bytes = 0
136
+ try {
137
+ for await (const chunk of response.body) {
138
+ bytes += chunk.length
139
+ if (bytes > max) throw new Error('源媒体超过 500MB,停止下载')
140
+ await handle.writeFile(chunk)
141
+ }
142
+ if (!bytes || (length && !response.headers.get('content-encoding') && bytes !== length))
143
+ throw new Error('源媒体下载不完整,停止处理')
144
+ } finally {
145
+ await handle.close()
146
+ }
147
+ return hashFile(target)
148
+ }
149
+ export const downloadVideo = (client, url, target) => downloadMedia(client, url, target, 'video')
150
+ export const downloadImage = (client, url, target) => downloadMedia(client, url, target, 'image')
151
+ // Signed references may be reissued for the same immutable media across days.
152
+ // Verify bytes through the normal anonymous file proxy, never decode tokens or
153
+ // compare only filenames/dimensions. Cache only within this client invocation.
154
+ const referenceChecks = new WeakMap()
155
+ async function sameMediaReferences(client, left, right, type) {
156
+ if (left === right) return true
157
+ if (![left, right].every((ref) => typeof ref === 'string' && /^fr1_[A-Za-z0-9_=-]+$/.test(ref)))
158
+ return false
159
+ let checks = referenceChecks.get(client)
160
+ if (!checks) referenceChecks.set(client, (checks = new Map()))
161
+ const fingerprint = (ref) => {
162
+ const cacheKey = type + ':' + ref
163
+ if (!checks.has(cacheKey)) {
164
+ const promise = (async () => {
165
+ const response = await requestFile(
166
+ client.baseUrl + '/files/' + encodeURIComponent(ref),
167
+ client.timeout
168
+ )
169
+ const length = Number(response.headers.get('content-length')) || 0
170
+ const max = 500 * 1024 * 1024
171
+ if (
172
+ !response.ok ||
173
+ !response.headers
174
+ .get('content-type')
175
+ ?.toLowerCase()
176
+ .startsWith(type + '/') ||
177
+ length > max
178
+ ) {
179
+ await response.body?.cancel()
180
+ throw new Error('无法核验更新的媒体引用;停止,不跳过来源校验')
181
+ }
182
+ const hash = crypto.createHash('sha256')
183
+ let bytes = 0
184
+ for await (const chunk of response.body) {
185
+ bytes += chunk.length
186
+ if (bytes > max) throw new Error('引用核验的媒体超过 500MB')
187
+ hash.update(chunk)
188
+ }
189
+ if (!bytes || (length && !response.headers.get('content-encoding') && bytes !== length))
190
+ throw new Error('引用核验的媒体读取不完整')
191
+ return { bytes, sha256: hash.digest('hex') }
192
+ })()
193
+ checks.set(cacheKey, promise)
194
+ promise.catch(() => checks.delete(cacheKey))
195
+ }
196
+ return checks.get(cacheKey)
197
+ }
198
+ const a = await fingerprint(left),
199
+ b = await fingerprint(right)
200
+ return a.bytes === b.bytes && a.sha256 === b.sha256
201
+ }
202
+ export const sameVideoReferences = (client, left, right) =>
203
+ sameMediaReferences(client, left, right, 'video')
204
+ export const sameImageReferences = (client, left, right) =>
205
+ sameMediaReferences(client, left, right, 'image')
206
+ export async function probeMedia(file, tools) {
207
+ const bytes = await runMedia(tools.ffprobe, [
208
+ '-v',
209
+ 'error',
210
+ '-protocol_whitelist',
211
+ 'file,pipe',
212
+ '-select_streams',
213
+ 'v:0',
214
+ '-show_streams',
215
+ '-show_format',
216
+ '-of',
217
+ 'json',
218
+ file
219
+ ])
220
+ let data
221
+ try {
222
+ data = JSON.parse(bytes)
223
+ } catch {
224
+ throw mediaError('媒体元数据响应无效')
225
+ }
226
+ const stream = data.streams?.[0]
227
+ const duration = Number(stream?.duration || data.format?.duration)
228
+ if (
229
+ !stream ||
230
+ !Number.isSafeInteger(stream.width) ||
231
+ !Number.isSafeInteger(stream.height) ||
232
+ stream.width <= 0 ||
233
+ stream.height <= 0 ||
234
+ stream.width * stream.height > 64000000
235
+ )
236
+ throw mediaError('源媒体缺少有效尺寸或超过 6400 万像素')
237
+ const [a, b] = String(stream.avg_frame_rate || '')
238
+ .split('/')
239
+ .map(Number)
240
+ return {
241
+ width: stream.width,
242
+ height: stream.height,
243
+ duration,
244
+ fps: b ? a / b : 0,
245
+ codec: stream.codec_name,
246
+ rotation: Number(
247
+ stream.side_data_list?.find((item) => item.rotation !== undefined)?.rotation ||
248
+ stream.tags?.rotate ||
249
+ 0
250
+ ),
251
+ orientation: Number(stream.tags?.orientation || 1)
252
+ }
253
+ }
254
+ export async function extractFrames(source, times, targets, tools) {
255
+ const metadata = await probeMedia(source, tools)
256
+ if (
257
+ !Number.isFinite(metadata.duration) ||
258
+ metadata.duration <= 0 ||
259
+ times.some((ms) => ms >= metadata.duration * 1000)
260
+ )
261
+ throw new Error('截帧时刻必须早于实际视频末尾')
262
+ const tolerance = Math.max(1, 500 / (metadata.fps > 0 ? metadata.fps : 30))
263
+ if (
264
+ times.some((time, index) =>
265
+ times.slice(0, index).some((other) => Math.abs(time - other) < tolerance)
266
+ )
267
+ )
268
+ throw new Error('截帧时刻过近,属于重复选帧;请按视频帧步长选择')
269
+ // Match the frame whose presentation interval contains the requested time,
270
+ // rather than seeking to the next frame or assuming a constant frame rate.
271
+ const raw = await runMedia(tools.ffprobe, [
272
+ '-v',
273
+ 'error',
274
+ '-protocol_whitelist',
275
+ 'file,pipe',
276
+ '-select_streams',
277
+ 'v:0',
278
+ '-show_frames',
279
+ '-show_entries',
280
+ 'frame=best_effort_timestamp_time',
281
+ '-of',
282
+ 'json',
283
+ source
284
+ ])
285
+ let frames
286
+ try {
287
+ frames = JSON.parse(raw).frames
288
+ } catch {
289
+ throw mediaError('视频帧时间戳无效')
290
+ }
291
+ const stamps = frames?.map((frame) => Number(frame.best_effort_timestamp_time))
292
+ if (
293
+ !stamps?.length ||
294
+ stamps.some((t) => !Number.isFinite(t)) ||
295
+ stamps.some((t, i) => i && t < stamps[i - 1])
296
+ )
297
+ throw mediaError('视频缺少单调的帧时间戳,不能准确截帧')
298
+ const origin = stamps[0],
299
+ entries = []
300
+ for (let i = 0; i < times.length; i++) {
301
+ let index = 0
302
+ while (index + 1 < stamps.length && (stamps[index + 1] - origin) * 1000 <= times[i] + 0.001)
303
+ index++
304
+ await runMedia(tools.ffmpeg, [
305
+ '-nostdin',
306
+ '-v',
307
+ 'error',
308
+ '-protocol_whitelist',
309
+ 'file,pipe',
310
+ '-i',
311
+ source,
312
+ '-map',
313
+ '0:v:0',
314
+ '-vf',
315
+ 'select=eq(n\\,' + index + ')',
316
+ '-fps_mode',
317
+ 'passthrough',
318
+ '-frames:v',
319
+ '1',
320
+ '-q:v',
321
+ '2',
322
+ '-n',
323
+ targets[i]
324
+ ])
325
+ const head = await fs.open(targets[i], 'r')
326
+ try {
327
+ const bytes = Buffer.alloc(3)
328
+ await head.read(bytes, 0, 3, 0)
329
+ if (!bytes.equals(Buffer.from([255, 216, 255]))) throw mediaError('截帧产物不是有效 JPEG')
330
+ } finally {
331
+ await head.close()
332
+ }
333
+ const image = await probeMedia(targets[i], tools)
334
+ entries.push({
335
+ time_ms: times[i],
336
+ frame_time_ms: Math.round((stamps[index] - origin) * 1000),
337
+ width: image.width,
338
+ height: image.height,
339
+ ...(await hashFile(targets[i]))
340
+ })
341
+ }
342
+ return entries
343
+ }
@@ -0,0 +1,76 @@
1
+ import { spaceContext } from './canvas.js'
2
+
3
+ // Ordinary node controls only. Development evidence: ImageNode, VideoNode,
4
+ // nodeFactories.js and shared/constants/modelOptions.js in the web application.
5
+ // This projection is discovery data, never a generation request or approval.
6
+ const values = (list) => Array.isArray(list)
7
+ ? [...new Set(list.map((v) => String(v || '').trim()).filter(Boolean))] : []
8
+ const preferred = (list, value) => list.includes(value) ? value : list[0] || null
9
+ export function imageQualityDefault(model) {
10
+ const options = values(model.quality_options)
11
+ return options.includes('medium') ? 'medium'
12
+ : preferred(options, String(model.quality || model.quality_default || '').trim()) || ''
13
+ }
14
+ export function projectModelOptions(model) {
15
+ const type = model.content_type
16
+ const controls = []
17
+ const problems = []
18
+ const draftName = String(model.label || '').trim().toLowerCase() === 'seedance 2.5 draft'
19
+ const draft = type === 'video' && draftName && model.is_sample_mode === true
20
+ if (type === 'video' && draftName !== (model.is_sample_mode === true))
21
+ problems.push('样片模型名称与目录 is_sample_mode 不一致,需核对当前部署兼容性')
22
+ const seedance25 = ['seedance-2.5', 'seedance2.5', 'seedance 2.5'].includes(String(model.label || '').trim().toLowerCase())
23
+ const add = (key, label, options, suggested) => {
24
+ if (options.length) controls.push({ key, label, kind: 'choice', options,
25
+ suggested, node_path: `data.${key}`, mirror_node_paths: [`data.extra.${key}`] })
26
+ }
27
+ if (['image', 'video'].includes(type)) {
28
+ const aspects = values(model.aspect_ratios)
29
+ const resolutions = draft
30
+ ? ['480p'] : values(model.resolutions)
31
+ add('aspect_ratio', '画面比例', aspects, preferred(aspects, '16:9'))
32
+ add('resolution', '分辨率', resolutions, preferred(resolutions, type === 'image' ? '1K' : '720p'))
33
+ if (draft) {
34
+ const resolution = controls.find(c => c.key === 'resolution')
35
+ Object.assign(resolution, { kind: 'fixed', value: '480p', reason: '前端样片模式锁定分辨率' })
36
+ if (!values(model.resolutions).includes('480p')) problems.push('样片目录缺少 480p,当前 CLI 无法准备此配置')
37
+ }
38
+ if (type === 'image') {
39
+ add('quality', '图片质量', values(model.quality_options), imageQualityDefault(model))
40
+ controls.push({ key: 'image_count', label: '图片数量', kind: 'fixed', value: 1,
41
+ node_path: 'data.image_count', mirror_node_paths: ['data.extra.image_count'], reason: '当前普通图片请求准备仅接入单张' })
42
+ } else {
43
+ const bound = (value, fallback) => value == null || String(value).trim() === '' ? fallback : Number(value)
44
+ const min = bound(model.durations?.min, 4)
45
+ const max = bound(model.durations?.max, 15)
46
+ if (!Number.isFinite(min) || !Number.isFinite(max) || min <= 0 || max < min)
47
+ problems.push('模型时长范围无效,不能提供可执行时长选项')
48
+ else controls.push({ key: 'duration', label: '视频时长(秒)', kind: 'range', min, max,
49
+ step: 1, suggested: Math.min(max, Math.max(min, 5)), node_path: 'data.duration' })
50
+ if (seedance25) for (const control of controls) control.not_applicable_modes = ['video_editing']
51
+ }
52
+ }
53
+ if (model.member_locked === true) problems.push('当前空间会员权限受限')
54
+ return {
55
+ label: model.label, content_type: type, selectable: problems.length === 0,
56
+ ...(problems.length ? { unavailable_reason: problems.join(';') } : {}),
57
+ workflow: 'ordinary_generation',
58
+ model_node_path: 'data.model_name', controls,
59
+ // Capability information is deliberately separate from editable controls.
60
+ generation_modes: Array.isArray(model.generation_modes) ? model.generation_modes : [],
61
+ requires_parameter_confirmation: true
62
+ }
63
+ }
64
+ export async function modelOptions(client, { space, teamUid, type }) {
65
+ if (!['text', 'image', 'video'].includes(type)) throw new Error('--type 必须是 text/image/video')
66
+ const context = spaceContext({ space, teamUid })
67
+ const query = new URLSearchParams({ ...context, content_type: type })
68
+ const catalog = await client.raw(`/canvas/models?${query}`)
69
+ if (!Array.isArray(catalog.items)) throw new Error('模型目录缺少 items,不能生成交互选项')
70
+ return {
71
+ space, team_uid: context.team_uid || null, content_type: type,
72
+ items: catalog.items.filter((m) => m.content_type === type).map(projectModelOptions),
73
+ submitted: false,
74
+ next: '通过交互卡片选择模型及适用参数,保存节点后运行 generation prepare;本输出不代表用户已确认'
75
+ }
76
+ }