@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
@@ -0,0 +1,385 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import { atomic } from './store.js'
5
+ import { scopedCanvas } from './materials.js'
6
+ import { segment, uploadParts } from './workflows.js'
7
+ import { bindUpload } from './resource.js'
8
+ import { hashFile } from './media.js'
9
+ const text = (value) => String(value ?? '').trim()
10
+ const digest = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
11
+ const fail = (code, message, error) =>
12
+ Object.assign(new Error(message), {
13
+ code,
14
+ ...(error
15
+ ? {
16
+ cause: error,
17
+ diagnostic: {
18
+ ...(error.status ? { http_status: error.status } : {}),
19
+ ...(error.apiCode ? { api_code: error.apiCode } : {}),
20
+ ...(error.requestId ? { request_id: error.requestId } : {})
21
+ }
22
+ }
23
+ : {})
24
+ })
25
+
26
+ // Shared persistence/upload contract for locally processed image outputs.
27
+ // Callers own source validation, geometry, decoding and operation journals.
28
+ export function imageOutputWorkflow({
29
+ recordPath,
30
+ readContext,
31
+ nodeTitle,
32
+ nodePosition,
33
+ edgeRole,
34
+ edgePrefix,
35
+ codePrefix,
36
+ mime,
37
+ summarize,
38
+ nodeType = () => 'image_upload'
39
+ }) {
40
+ async function persistNodes(client, profile, record, current) {
41
+ const doc = current.doc
42
+ const existing = record.items.map((item) => doc.nodes.find((node) => node.id === item.nodeId))
43
+ if (existing.every(Boolean)) {
44
+ for (let i = 0; i < existing.length; i++) {
45
+ const node = existing[i],
46
+ item = record.items[i]
47
+ if (
48
+ node.type !== nodeType(record, item) ||
49
+ (node.data?.extra?.node_kind &&
50
+ node.data.extra.node_kind !==
51
+ (nodeType(record, item) === 'image' ? 'generation' : 'asset')) ||
52
+ (node.data?.file_name && node.data.file_name !== item.fileName) ||
53
+ !doc.edges.some(
54
+ (e) =>
55
+ e.source === record.input.node_id && e.target === node.id && e.data?.role === edgeRole
56
+ )
57
+ )
58
+ throw new Error('图片处理结果节点或来源连线已变化,停止保存')
59
+ }
60
+ record.nodesSaved = true
61
+ await atomic(recordPath(profile, record.operation), record)
62
+ return
63
+ }
64
+ if (record.nodesSaved || record.saveAttempted || existing.some(Boolean))
65
+ throw fail(
66
+ codePrefix + '_SYNC_PENDING',
67
+ '图片处理节点/连线缺失或保存结果不明;先核对用户删除/画布内容,不自动重建'
68
+ )
69
+ if (
70
+ !Number.isSafeInteger(doc.version) ||
71
+ !doc.viewport ||
72
+ !doc.meta ||
73
+ !Array.isArray(doc.edges)
74
+ )
75
+ throw new Error('图片处理画布快照不完整')
76
+ const nodes = structuredClone(doc.nodes),
77
+ edges = structuredClone(doc.edges)
78
+ for (const item of record.items) {
79
+ const node = {
80
+ id: item.nodeId,
81
+ type: nodeType(record, item),
82
+ position: nodePosition(nodes, current.node),
83
+ data: {
84
+ title: nodeTitle(item),
85
+ mediaType: 'image',
86
+ url: '',
87
+ file_name: item.fileName,
88
+ file_size: item.local.bytes,
89
+ width: item.local.width,
90
+ height: item.local.height,
91
+ uploading: true,
92
+ ...(nodeType(record, item) === 'image'
93
+ ? {
94
+ status: 'processing',
95
+ runtime: { status: 'processing', operation: 'upload', progress: 0, error: '' }
96
+ }
97
+ : {}),
98
+ extra: {
99
+ node_kind: nodeType(record, item) === 'image_upload' ? 'asset' : 'generation',
100
+ aspect_ratio: item.local.width + ':' + item.local.height
101
+ }
102
+ }
103
+ }
104
+ nodes.push(node)
105
+ edges.push({
106
+ id: edgePrefix + item.nodeId,
107
+ source: record.input.node_id,
108
+ target: item.nodeId,
109
+ sourceHandle: '',
110
+ targetHandle: '',
111
+ type: 'default',
112
+ data: { role: edgeRole }
113
+ })
114
+ }
115
+ record.saveAttempted = true
116
+ await atomic(recordPath(profile, record.operation), record)
117
+ try {
118
+ const saved = await client.raw(
119
+ '/canvas/docs/' + segment(doc.id),
120
+ {
121
+ method: 'PUT',
122
+ body: {
123
+ version: doc.version,
124
+ name: doc.name,
125
+ viewport: doc.viewport,
126
+ nodes,
127
+ edges,
128
+ meta: doc.meta
129
+ }
130
+ },
131
+ { retry: false }
132
+ )
133
+ if (
134
+ saved.id !== doc.id ||
135
+ saved.version <= doc.version ||
136
+ record.items.some(
137
+ (item) =>
138
+ !saved.nodes?.some((n) => n.id === item.nodeId) ||
139
+ !saved.edges?.some((e) => e.id === edgePrefix + item.nodeId)
140
+ )
141
+ )
142
+ throw new Error('节点保存响应不完整')
143
+ record.nodesSaved = true
144
+ await atomic(recordPath(profile, record.operation), record)
145
+ } catch (error) {
146
+ record.state = 'sync_pending'
147
+ if (error.status === 409 || error.apiCode === 3002) {
148
+ record.saveAttempted = false
149
+ }
150
+ await atomic(recordPath(profile, record.operation), record)
151
+ throw fail(
152
+ codePrefix + '_SYNC_PENDING',
153
+ '图片处理已在本地导出,节点保存未确认;查询原画布后恢复,不重复处理',
154
+ error
155
+ )
156
+ }
157
+ }
158
+ function validateUpload(response, record, item) {
159
+ if (
160
+ response?.source_doc_id !== record.input.doc_id ||
161
+ response.source_node_id !== item.nodeId ||
162
+ response.segment_type !== 'image' ||
163
+ !response.result?.file_ref ||
164
+ !response.result.url ||
165
+ !/^image\//.test(response.result.mime_type || '') ||
166
+ response.result.width !== item.local.width ||
167
+ response.result.height !== item.local.height ||
168
+ !/^[1-9]\d*$/.test(text(response.task_id))
169
+ )
170
+ throw fail(
171
+ codePrefix + '_UPLOAD_UNKNOWN',
172
+ '上传响应缺少真实图片/任务关联;查询原节点和任务,不重复上传'
173
+ )
174
+ return response
175
+ }
176
+ async function reconcile(client, record, item) {
177
+ const list = await client.raw(
178
+ '/canvas/generations?' +
179
+ new URLSearchParams({
180
+ doc_id: record.input.doc_id,
181
+ node_id: item.nodeId,
182
+ status: 'completed',
183
+ limit: '100'
184
+ })
185
+ )
186
+ const current = await readContext(client, record.input)
187
+ if (digest(current.binding) !== digest(record.binding))
188
+ throw new Error('恢复上传前源素材已变化,停止')
189
+ const node = current.doc.nodes.find((n) => n.id === item.nodeId)
190
+ const uploadTask = String(node?.data?.task_id || '')
191
+ const uploadRef = node?.data?.file_ref
192
+ const candidates = list.items?.filter(
193
+ (task) =>
194
+ task.doc_id === record.input.doc_id &&
195
+ task.node_id === item.nodeId &&
196
+ task.segment_type === 'image' &&
197
+ (task.model_name === 'local-upload' ||
198
+ (uploadTask &&
199
+ String(task.task_id ?? task.id) === uploadTask &&
200
+ uploadRef &&
201
+ task.result?.file_ref === uploadRef &&
202
+ task.credit_cost === 0 &&
203
+ task.billing_status === 'charged')) &&
204
+ task.status === 'completed' &&
205
+ task.result?.filename === item.fileName &&
206
+ task.result?.file_ref &&
207
+ task.result?.url
208
+ )
209
+ if (candidates?.length !== 1)
210
+ throw fail(
211
+ codePrefix + '_UPLOAD_UNKNOWN',
212
+ '原上传 complete 结果未知;原节点未查到唯一真实任务,不重新 complete 或重传'
213
+ )
214
+ const task = candidates[0]
215
+ item.response = validateUpload(
216
+ {
217
+ source_doc_id: task.doc_id,
218
+ source_node_id: task.node_id,
219
+ segment_type: 'image',
220
+ task_id: task.task_id ?? task.id,
221
+ result: task.result
222
+ },
223
+ record,
224
+ item
225
+ )
226
+ item.state = 'uploaded'
227
+ }
228
+ async function runUploads(client, profile, record) {
229
+ record.state = 'uploading'
230
+ await atomic(recordPath(profile, record.operation), record)
231
+ for (const item of record.items) {
232
+ const current = await readContext(client, record.input)
233
+ if (digest(record.binding) !== digest(current.binding))
234
+ throw new Error('上传前源素材已变化,停止')
235
+ const node = current.doc.nodes.find((node) => node.id === item.nodeId)
236
+ if (
237
+ !node ||
238
+ node.type !== nodeType(record, item) ||
239
+ !current.doc.edges.some(
240
+ (e) =>
241
+ e.source === record.input.node_id &&
242
+ e.target === item.nodeId &&
243
+ e.data?.role === edgeRole
244
+ )
245
+ )
246
+ throw new Error('图片处理节点或连线已被删除,不自动重建')
247
+ const currentRef =
248
+ node.data?.file_ref || node.data?.extra?.file_ref || node.data?.result?.file_ref
249
+ if (
250
+ currentRef &&
251
+ currentRef !== item.response?.result?.file_ref &&
252
+ !['completing', 'unknown'].includes(item.state)
253
+ )
254
+ throw new Error('图片处理节点已换成其他素材;保留用户编辑')
255
+ if (['completing', 'unknown'].includes(item.state)) {
256
+ record.state = 'upload_unknown'
257
+ await atomic(recordPath(profile, record.operation), record)
258
+ await reconcile(client, record, item)
259
+ record.state = 'uploading'
260
+ await atomic(recordPath(profile, record.operation), record)
261
+ }
262
+ if (item.state === 'session_unknown') {
263
+ record.state = 'upload_unknown'
264
+ await atomic(recordPath(profile, record.operation), record)
265
+ throw fail(
266
+ codePrefix + '_UPLOAD_UNKNOWN',
267
+ '上传会话创建结果未知,停止;不自动创建第二个会话'
268
+ )
269
+ }
270
+ if (!item.response) {
271
+ const image = path.join(record.directory, item.fileName)
272
+ const local = await hashFile(image)
273
+ if (local.sha256 !== item.local.sha256 || local.bytes !== item.local.bytes)
274
+ throw new Error('本地图片处理产物发生变化,不上传')
275
+ const endpoint =
276
+ '/canvas/docs/' + segment(record.input.doc_id) + '/nodes/' + segment(item.nodeId)
277
+ if (!item.session) {
278
+ item.state = 'session_unknown'
279
+ record.state = 'upload_unknown'
280
+ await atomic(recordPath(profile, record.operation), record)
281
+ try {
282
+ item.session = await client.raw(
283
+ endpoint + '/upload-session',
284
+ {
285
+ method: 'POST',
286
+ body: {
287
+ filename: item.fileName,
288
+ file_size: local.bytes,
289
+ expected_type: 'image',
290
+ mime_type: mime
291
+ }
292
+ },
293
+ { retry: false }
294
+ )
295
+ } catch (error) {
296
+ throw fail(
297
+ codePrefix + '_UPLOAD_UNKNOWN',
298
+ '创建图片处理上传会话未确认;停止并保留原操作',
299
+ error
300
+ )
301
+ }
302
+ item.state = 'uploading'
303
+ record.state = 'uploading'
304
+ await atomic(recordPath(profile, record.operation), record)
305
+ }
306
+ if (!item.parts) {
307
+ const handle = await fs.open(image, 'r')
308
+ try {
309
+ item.parts = await uploadParts(client, handle, await handle.stat(), item.session, mime)
310
+ } finally {
311
+ await handle.close()
312
+ }
313
+ await atomic(recordPath(profile, record.operation), record)
314
+ }
315
+ item.state = 'completing'
316
+ await atomic(recordPath(profile, record.operation), record)
317
+ try {
318
+ item.response = validateUpload(
319
+ await client.raw(
320
+ endpoint + '/upload-complete',
321
+ {
322
+ method: 'POST',
323
+ body: {
324
+ upload_id: item.session.upload_id,
325
+ object_key: item.session.object_key,
326
+ expected_parts: item.parts.length,
327
+ parts: item.parts
328
+ }
329
+ },
330
+ { retry: false }
331
+ ),
332
+ record,
333
+ item
334
+ )
335
+ } catch (error) {
336
+ item.state = 'unknown'
337
+ record.state = 'upload_unknown'
338
+ await atomic(recordPath(profile, record.operation), record)
339
+ throw fail(
340
+ codePrefix + '_UPLOAD_UNKNOWN',
341
+ '图片处理上传完成结果未确认;只查询原节点/任务恢复,不再次 complete',
342
+ error
343
+ )
344
+ }
345
+ item.state = 'uploaded'
346
+ await atomic(recordPath(profile, record.operation), record)
347
+ }
348
+ if (!item.bound) {
349
+ try {
350
+ await bindUpload(client, {
351
+ docId: record.input.doc_id,
352
+ nodeId: item.nodeId,
353
+ result: item.response,
354
+ imageNode: nodeType(record, item) === 'image'
355
+ })
356
+ // Clear the preparation flag only after real upload binding succeeds.
357
+ const doc = await scopedCanvas(
358
+ client,
359
+ record.input.doc_id,
360
+ record.input.space,
361
+ record.input.team_uid
362
+ )
363
+ const node = doc.nodes.find((n) => n.id === item.nodeId)
364
+ if (node?.data?.extra?.file_ref !== item.response.result.file_ref)
365
+ throw new Error('图片处理引用读回不一致')
366
+ item.bound = true
367
+ item.state = 'completed'
368
+ await atomic(recordPath(profile, record.operation), record)
369
+ } catch (error) {
370
+ record.state = 'sync_pending'
371
+ await atomic(recordPath(profile, record.operation), record)
372
+ throw fail(
373
+ codePrefix + '_SYNC_PENDING',
374
+ '图片已上传,画布绑定未确认;恢复原响应,不重传图片',
375
+ error
376
+ )
377
+ }
378
+ }
379
+ }
380
+ record.state = 'completed'
381
+ await atomic(recordPath(profile, record.operation), record)
382
+ return summarize(record)
383
+ }
384
+ return { persistNodes, runUploads }
385
+ }
@@ -0,0 +1,23 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+
5
+ // A failed download/write never occupies the requested filename. Linking in the
6
+ // same directory publishes a complete file without replacing an existing file.
7
+ export async function exclusiveOutput(output, write) {
8
+ const target = path.resolve(output)
9
+ const temporary = path.join(path.dirname(target), '.' + crypto.randomUUID() + '.part')
10
+ try {
11
+ try {
12
+ await fs.lstat(target)
13
+ throw Object.assign(new Error('目标文件已存在;请指定新文件,不覆盖'), { code: 'EEXIST' })
14
+ } catch (error) {
15
+ if (error.code !== 'ENOENT') throw error
16
+ }
17
+ const result = await write(temporary)
18
+ await fs.link(temporary, target)
19
+ return { output: target, ...result }
20
+ } finally {
21
+ await fs.rm(temporary, { force: true })
22
+ }
23
+ }
@@ -0,0 +1,55 @@
1
+ import path from 'node:path'
2
+ import fs from 'node:fs/promises'
3
+ import crypto from 'node:crypto'
4
+ import { materialGet } from './materials.js'
5
+ import { requestFile } from './api.js'
6
+ import { exclusiveOutput } from './local-output.js'
7
+ import { downloadName } from './download-name.js'
8
+
9
+ // Material download is a separate permission gate. A URL returned by list/get
10
+ // never substitutes for a rejected download, even if that URL is still valid.
11
+ export async function downloadMaterial(client, { space, teamUid, assetId, output, expectedHash }) {
12
+ if (typeof output !== 'string' || !output.trim()) throw new Error('素材下载必须提供--output FILE')
13
+ if (expectedHash !== undefined && (typeof expectedHash !== 'string' || !/^[a-fA-F0-9]{64}$/.test(expectedHash)))
14
+ throw new Error('--sha256 必须是64位十六进制')
15
+ // Resolve scope through the actual folder list and asset detail before asking
16
+ // the server for a new original-file link. No filename or format override.
17
+ const asset = await materialGet(client, { space, teamUid, assetId })
18
+ if (String(asset.id) !== String(assetId) || !/^(image|video|audio)\//.test(asset.mime_type || '') || !asset.file_ref)
19
+ throw new Error('素材身份、媒体类型或原文件引用不完整,停止下载')
20
+ const major = asset.mime_type.split('/')[0]
21
+ const link = await client.raw('/material-space/assets/' + encodeURIComponent(String(assetId)) + '/download',
22
+ {}, { retry: false })
23
+ if (!link?.file_ref || typeof link.url !== 'string' || !link.url.trim() ||
24
+ (link.format !== undefined && link.format !== 'original'))
25
+ throw new Error('素材原文件下载接口未返回有效原格式链接;不改用列表地址或转换格式')
26
+ const url = new URL(link.url, new URL(client.baseUrl).origin)
27
+ if (url.searchParams.has('format') && url.searchParams.get('format') !== 'original')
28
+ throw new Error('原文件下载链接包含转换参数;停止,不更改服务端URL或偷偷转换')
29
+ const saved = await exclusiveOutput(output, async temporary => {
30
+ const response = await requestFile(url, client.timeout)
31
+ const mime = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
32
+ if (mime !== 'application/octet-stream' && !mime.startsWith(major + '/')) {
33
+ await response.body?.cancel()
34
+ throw new Error('素材下载内容类型与原素材不一致;未保存文件')
35
+ }
36
+ const handle = await fs.open(temporary, 'wx', 0o600)
37
+ const hash = crypto.createHash('sha256')
38
+ let bytes = 0
39
+ try {
40
+ for await (const chunk of response.body) {
41
+ await handle.writeFile(chunk)
42
+ hash.update(chunk)
43
+ bytes += chunk.length
44
+ }
45
+ const sha256 = hash.digest('hex'), length = response.headers.get('content-length')
46
+ if (!bytes || (length !== null && !response.headers.get('content-encoding') && bytes !== Number(length)) ||
47
+ (expectedHash && sha256 !== expectedHash.toLowerCase()))
48
+ throw new Error('素材下载文件完整性校验失败;未保存文件')
49
+ return { bytes, sha256, server_filename: downloadName(response.headers, link.filename) }
50
+ } finally { await handle.close() }
51
+ })
52
+ return { ...saved, output: path.resolve(output), asset_id: asset.id, format: 'original',
53
+ ...(typeof link.filename === 'string' ? { filename: link.filename } : {}),
54
+ canvas_written: false, generation_submitted: false, processing_submitted: false, library_saved: false }
55
+ }