@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.
- package/.agents/skills/uzoo-anime-cli/SKILL.md +83 -0
- package/.agents/skills/uzoo-anime-cli/references/agreement-workflow.md +51 -0
- package/.agents/skills/uzoo-anime-cli/references/canvas-workflow.md +53 -0
- package/.agents/skills/uzoo-anime-cli/references/commands.md +250 -0
- package/.agents/skills/uzoo-anime-cli/references/generation-workflow.md +152 -0
- package/.agents/skills/uzoo-anime-cli/references/image-tools-workflow.md +175 -0
- package/.agents/skills/uzoo-anime-cli/references/interaction-workflow.md +67 -0
- package/.agents/skills/uzoo-anime-cli/references/material-graph-workflow.md +123 -0
- package/.agents/skills/uzoo-anime-cli/references/node-capabilities.md +95 -0
- package/.agents/skills/uzoo-anime-cli/references/node-workflow.md +94 -0
- package/.agents/skills/uzoo-anime-cli/references/remake-workflow.md +54 -0
- package/.agents/skills/uzoo-anime-cli/references/repaint-workflow.md +300 -0
- package/.agents/skills/uzoo-anime-cli/references/seedance-review-workflow.md +41 -0
- package/.agents/skills/uzoo-anime-cli/references/video-tools-workflow.md +71 -0
- package/CHANGELOG.md +92 -0
- package/LICENSE +21 -0
- package/README.md +242 -3
- package/assets/README.md +4 -0
- package/assets/favicon-uzoo.png +0 -0
- package/assets/logo-uzoo.svg +7 -0
- 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
- 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
- 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
- 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
- package/docs/M0-M5/345/256/236/346/226/275/344/270/216/351/252/214/346/224/266.md +89 -0
- 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
- 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
- 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
- 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
- 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
- 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
- package/docs//345/274/200/345/217/221/350/256/241/345/210/222.md +127 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/docs//351/230/266/346/256/2653B/351/252/214/346/224/266/344/272/244/346/216/245.md +44 -0
- package/docs//351/230/266/346/256/265/344/270/200/351/252/214/346/224/266.md +60 -0
- 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
- 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
- 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
- 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
- package/examples/matting.json +5 -0
- package/examples/real-business/image-request.template.json +7 -0
- package/examples/real-business/node-patch.template.json +11 -0
- package/extensions/uzoo-cli-login/manifest.json +9 -0
- package/extensions/uzoo-cli-login/popup.css +1 -0
- package/extensions/uzoo-cli-login/popup.html +14 -0
- package/extensions/uzoo-cli-login/popup.js +65 -0
- package/package.json +56 -6
- package/scripts/check-package-resources.mjs +56 -0
- package/scripts/check-release.mjs +19 -0
- package/scripts/pack-local.mjs +33 -0
- package/scripts/release-policy.mjs +22 -0
- package/src/agreement-page.js +62 -0
- package/src/agreement.js +419 -0
- package/src/annotation-media.js +386 -0
- package/src/api.js +168 -0
- package/src/browser-login.js +13 -0
- package/src/callback-page.js +44 -0
- package/src/canvas.js +105 -0
- package/src/cli-authorization.js +122 -0
- package/src/cli.js +459 -0
- package/src/download-name.js +17 -0
- package/src/draft-finalize.js +229 -0
- package/src/generation-graph.js +124 -0
- package/src/generation-input.js +472 -0
- package/src/graph-disconnect.js +89 -0
- package/src/graph.js +431 -0
- package/src/group-edit.js +53 -0
- package/src/group-layout.js +113 -0
- package/src/group-membership.js +126 -0
- package/src/history-add.js +119 -0
- package/src/image-annotation.js +246 -0
- package/src/image-crop.js +283 -0
- package/src/image-matting.js +82 -0
- package/src/image-media.js +89 -0
- package/src/image-output.js +385 -0
- package/src/local-output.js +23 -0
- package/src/material-download.js +55 -0
- package/src/material-save.js +254 -0
- package/src/materials.js +147 -0
- package/src/matting-background.js +73 -0
- package/src/matting-jobs.js +63 -0
- package/src/matting-worker.js +49 -0
- package/src/matting.js +170 -0
- package/src/media-copy.js +53 -0
- package/src/media.js +343 -0
- package/src/model-options.js +76 -0
- package/src/multipart.js +28 -0
- package/src/node-copy.js +95 -0
- package/src/node-delete.js +49 -0
- package/src/node-history.js +231 -0
- package/src/normal-login.js +118 -0
- package/src/package-info.js +8 -0
- package/src/png-alpha.js +60 -0
- package/src/protocol.js +83 -0
- package/src/remake.js +738 -0
- package/src/repaint-archive.js +53 -0
- package/src/repaint-delivery.js +783 -0
- package/src/repaint-export.js +63 -0
- package/src/repaint-graph.js +214 -0
- package/src/repaint-history.js +267 -0
- package/src/repaint-materials.js +601 -0
- package/src/repaint-reverse.js +614 -0
- package/src/repaint-sequence.js +132 -0
- package/src/repaint-settings.js +576 -0
- package/src/repaint-video.js +798 -0
- package/src/repaint.js +326 -0
- package/src/resource.js +110 -0
- package/src/review.js +435 -0
- package/src/service.js +85 -0
- package/src/store.js +100 -0
- package/src/video-clip.js +381 -0
- package/src/video-frame.js +296 -0
- package/src/workflows.js +214 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { isDeepStrictEqual as equal } from 'node:util'
|
|
4
|
+
import { atomic, json, root, safeName } from './store.js'
|
|
5
|
+
import { request } from './api.js'
|
|
6
|
+
import { hashFile } from './media.js'
|
|
7
|
+
import { multipartText } from './multipart.js'
|
|
8
|
+
import { jobPath } from './repaint-graph.js'
|
|
9
|
+
import { requireServiceReady } from './service.js'
|
|
10
|
+
import {
|
|
11
|
+
repaintSettingsContext,
|
|
12
|
+
requireRepaintSettingsIdle,
|
|
13
|
+
appendRepaintAttachment,
|
|
14
|
+
repaintSettingsPath
|
|
15
|
+
} from './repaint-settings.js'
|
|
16
|
+
|
|
17
|
+
const api = (client, endpoint, options = {}) =>
|
|
18
|
+
client.raw(
|
|
19
|
+
endpoint,
|
|
20
|
+
{
|
|
21
|
+
...options,
|
|
22
|
+
headers: { ...options.headers, 'X-Client-Source': 'canvas' }
|
|
23
|
+
},
|
|
24
|
+
{ retry: false }
|
|
25
|
+
)
|
|
26
|
+
export const repaintUploadPath = (profile, operation) =>
|
|
27
|
+
path.join(
|
|
28
|
+
root(),
|
|
29
|
+
'repaint-uploads',
|
|
30
|
+
safeName(profile),
|
|
31
|
+
safeName(operation) + '.json'
|
|
32
|
+
)
|
|
33
|
+
const persist = (r) => atomic(repaintUploadPath(r.profile, r.operation), r)
|
|
34
|
+
const fail = (message, cause) =>
|
|
35
|
+
Object.assign(new Error(message), {
|
|
36
|
+
code: 'REPAINT_UPLOAD_UNKNOWN',
|
|
37
|
+
...(cause
|
|
38
|
+
? {
|
|
39
|
+
cause,
|
|
40
|
+
status: cause.status,
|
|
41
|
+
apiCode: cause.apiCode,
|
|
42
|
+
requestId: cause.requestId
|
|
43
|
+
}
|
|
44
|
+
: {})
|
|
45
|
+
})
|
|
46
|
+
const categories = {
|
|
47
|
+
image: ['character', 'scene', 'prop', 'other'],
|
|
48
|
+
audio: ['audio', 'voice']
|
|
49
|
+
}
|
|
50
|
+
const mimeTypes = {
|
|
51
|
+
'.png': 'image/png',
|
|
52
|
+
'.jpg': 'image/jpeg',
|
|
53
|
+
'.jpeg': 'image/jpeg',
|
|
54
|
+
'.webp': 'image/webp',
|
|
55
|
+
'.mp3': 'audio/mpeg',
|
|
56
|
+
'.wav': 'audio/wav',
|
|
57
|
+
'.m4a': 'audio/mp4',
|
|
58
|
+
'.aac': 'audio/aac',
|
|
59
|
+
'.pdf': 'application/pdf'
|
|
60
|
+
}
|
|
61
|
+
export async function repaintUploadInput(value, route) {
|
|
62
|
+
if (
|
|
63
|
+
!['reference', 'attachment'].includes(route) ||
|
|
64
|
+
!value ||
|
|
65
|
+
Array.isArray(value) ||
|
|
66
|
+
typeof value !== 'object' ||
|
|
67
|
+
Object.keys(value).some(
|
|
68
|
+
(k) =>
|
|
69
|
+
![
|
|
70
|
+
'file',
|
|
71
|
+
...(route === 'reference' ? ['kind', 'category'] : [])
|
|
72
|
+
].includes(k)
|
|
73
|
+
) ||
|
|
74
|
+
typeof value.file !== 'string' ||
|
|
75
|
+
!value.file.trim()
|
|
76
|
+
)
|
|
77
|
+
throw new Error(
|
|
78
|
+
'上传输入需file;任务素材另需kind/category,不接受URL或素材库ID'
|
|
79
|
+
)
|
|
80
|
+
const file = path.resolve(value.file),
|
|
81
|
+
filename = path.basename(file),
|
|
82
|
+
mime = mimeTypes[path.extname(file).toLowerCase()]
|
|
83
|
+
if (!filename || filename.length > 255 || /[\x00-\x1f\x7f]/.test(filename))
|
|
84
|
+
throw new Error('素材文件名无效')
|
|
85
|
+
if (
|
|
86
|
+
route === 'reference' &&
|
|
87
|
+
!categories[value.kind]?.includes(value.category)
|
|
88
|
+
)
|
|
89
|
+
throw new Error(
|
|
90
|
+
'任务素材仅支持图片character/scene/prop/other,音频audio/voice;视频不可用'
|
|
91
|
+
)
|
|
92
|
+
if (
|
|
93
|
+
!mime ||
|
|
94
|
+
(route === 'reference'
|
|
95
|
+
? !mime.startsWith(value.kind + '/')
|
|
96
|
+
: !(mime.startsWith('image/') || mime === 'application/pdf'))
|
|
97
|
+
)
|
|
98
|
+
throw new Error('本路线文件格式不支持')
|
|
99
|
+
const stat = await fs.stat(file)
|
|
100
|
+
if (
|
|
101
|
+
!stat.isFile() ||
|
|
102
|
+
!Number.isSafeInteger(stat.size) ||
|
|
103
|
+
stat.size <= 0 ||
|
|
104
|
+
(route === 'attachment' && stat.size > 20 * 1024 * 1024)
|
|
105
|
+
)
|
|
106
|
+
throw new Error('文件为空/不是普通文件/超过附件20MiB限制')
|
|
107
|
+
return {
|
|
108
|
+
file,
|
|
109
|
+
filename,
|
|
110
|
+
mime,
|
|
111
|
+
...(await hashFile(file)),
|
|
112
|
+
...(route === 'reference'
|
|
113
|
+
? { kind: value.kind, category: value.category }
|
|
114
|
+
: {})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const currentBinding = (ctx) => ({
|
|
118
|
+
...ctx.binding,
|
|
119
|
+
custom_prompt: ctx.serverValue,
|
|
120
|
+
attachments: ctx.serverAttachments
|
|
121
|
+
})
|
|
122
|
+
function check(r, ctx, activation = false) {
|
|
123
|
+
const current = currentBinding(ctx)
|
|
124
|
+
const expected =
|
|
125
|
+
r.route === 'reference'
|
|
126
|
+
? {
|
|
127
|
+
...current,
|
|
128
|
+
custom_prompt: r.binding.custom_prompt,
|
|
129
|
+
attachments: r.binding.attachments
|
|
130
|
+
}
|
|
131
|
+
: current
|
|
132
|
+
if (r.owner !== ctx.parent.owner || !equal(r.binding, expected)) {
|
|
133
|
+
// Attachment activation owns its separate CAS journal, including before/target lists.
|
|
134
|
+
if (
|
|
135
|
+
!activation ||
|
|
136
|
+
r.owner !== ctx.parent.owner ||
|
|
137
|
+
!equal(r.binding, {
|
|
138
|
+
...currentBinding(ctx),
|
|
139
|
+
attachments: r.binding.attachments
|
|
140
|
+
})
|
|
141
|
+
)
|
|
142
|
+
throw new Error(
|
|
143
|
+
'上传的账号/job/方案/全局规则或附件已变化;不转移素材或覆盖编辑'
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
if (
|
|
147
|
+
ctx.nodeValue !== ctx.serverValue ||
|
|
148
|
+
(!activation && !equal(ctx.nodeAttachments, ctx.serverAttachments))
|
|
149
|
+
)
|
|
150
|
+
throw new Error('画布全局设置未同步,先恢复原保存')
|
|
151
|
+
}
|
|
152
|
+
export async function listRepaintMaterials(client, profile, parent) {
|
|
153
|
+
const ctx = await repaintSettingsContext(client, profile, parent)
|
|
154
|
+
const result = await api(client, jobPath(ctx.job.id) + '/reference-materials')
|
|
155
|
+
if (!Array.isArray(result?.items) || result.count !== result.items.length)
|
|
156
|
+
throw new Error('任务素材池列表不完整')
|
|
157
|
+
return {
|
|
158
|
+
repaint_operation: parent,
|
|
159
|
+
job_id: ctx.job.id,
|
|
160
|
+
...result,
|
|
161
|
+
generation_submitted: false
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function summary(r) {
|
|
165
|
+
return {
|
|
166
|
+
operation: r.operation,
|
|
167
|
+
repaint_operation: r.parentOperation,
|
|
168
|
+
route: r.route,
|
|
169
|
+
state: r.state,
|
|
170
|
+
job_id: r.binding.job_id,
|
|
171
|
+
plan_version: r.binding.plan_version,
|
|
172
|
+
file_name: r.input.filename,
|
|
173
|
+
bytes: r.input.bytes,
|
|
174
|
+
...(r.receipt ? { receipt: r.receipt } : {}),
|
|
175
|
+
...(r.activation ? { activation: r.activation } : {}),
|
|
176
|
+
generation_submitted: false,
|
|
177
|
+
processing_submitted: false,
|
|
178
|
+
material_library_saved: false,
|
|
179
|
+
upload_billable_oo: false,
|
|
180
|
+
limits:
|
|
181
|
+
r.route === 'attachment'
|
|
182
|
+
? { max_bytes: 20 * 1024 * 1024 }
|
|
183
|
+
: {
|
|
184
|
+
max_bytes: null,
|
|
185
|
+
authority:
|
|
186
|
+
'服务端任务素材限额;没有公开查询接口,不用普通素材库限额代替'
|
|
187
|
+
},
|
|
188
|
+
reference_generation_available: false,
|
|
189
|
+
attachment_processing_available: null,
|
|
190
|
+
attachment_processing_preflight:
|
|
191
|
+
'repaint delivery PARENT --segment-index N --operation NAME'
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function duplicate(client, profile, operation, ctx, input, route) {
|
|
195
|
+
const dir = path.dirname(repaintUploadPath(profile, operation))
|
|
196
|
+
const files = await fs.readdir(dir).catch((e) => {
|
|
197
|
+
if (e.code === 'ENOENT') return []
|
|
198
|
+
throw e
|
|
199
|
+
})
|
|
200
|
+
for (const name of files.filter((n) => n.endsWith('.json'))) {
|
|
201
|
+
const r = await json(path.join(dir, name))
|
|
202
|
+
if (
|
|
203
|
+
r.operation !== operation &&
|
|
204
|
+
r.baseUrl === client.baseUrl &&
|
|
205
|
+
r.owner === ctx.parent.owner &&
|
|
206
|
+
r.binding.job_id === ctx.job.id &&
|
|
207
|
+
((r.sessionAttempted && r.state !== 'completed') ||
|
|
208
|
+
(r.route === route && r.input.sha256 === input.sha256))
|
|
209
|
+
)
|
|
210
|
+
throw new Error(
|
|
211
|
+
'同job已有未结束上传或相同文件/用途的operation;先查询原操作,不能换名重复上传'
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export async function startRepaintUpload(
|
|
216
|
+
client,
|
|
217
|
+
profile,
|
|
218
|
+
parent,
|
|
219
|
+
operation,
|
|
220
|
+
route,
|
|
221
|
+
value,
|
|
222
|
+
confirmed = false
|
|
223
|
+
) {
|
|
224
|
+
safeName(operation)
|
|
225
|
+
if (route === 'attachment' && operation.length > 55)
|
|
226
|
+
throw new Error('附件operation最长55字符,预留设置记录后缀')
|
|
227
|
+
const input = await repaintUploadInput(value, route)
|
|
228
|
+
await requireServiceReady(client)
|
|
229
|
+
const ctx = await repaintSettingsContext(client, profile, parent)
|
|
230
|
+
await requireRepaintSettingsIdle(client, ctx, profile)
|
|
231
|
+
let r = await json(repaintUploadPath(profile, operation), null)
|
|
232
|
+
if (r) {
|
|
233
|
+
if (
|
|
234
|
+
r.baseUrl !== client.baseUrl ||
|
|
235
|
+
r.parentOperation !== parent ||
|
|
236
|
+
r.route !== route ||
|
|
237
|
+
!equal(r.input, input)
|
|
238
|
+
)
|
|
239
|
+
throw new Error('operation已绑定其他文件、用途、任务或服务')
|
|
240
|
+
} else {
|
|
241
|
+
if (
|
|
242
|
+
ctx.nodeValue !== ctx.serverValue ||
|
|
243
|
+
!equal(ctx.nodeAttachments, ctx.serverAttachments)
|
|
244
|
+
)
|
|
245
|
+
throw new Error('全局规则/附件已不同步,停止上传')
|
|
246
|
+
if (route === 'attachment') {
|
|
247
|
+
const pricing = await api(client, '/video-segmentation/pricing')
|
|
248
|
+
if (
|
|
249
|
+
!Number.isSafeInteger(pricing.attachment_max) ||
|
|
250
|
+
pricing.attachment_max < 1 ||
|
|
251
|
+
ctx.serverAttachments.length >= Math.min(10, pricing.attachment_max)
|
|
252
|
+
)
|
|
253
|
+
throw new Error('全局附件已达上限或限额未知,停止上传')
|
|
254
|
+
}
|
|
255
|
+
await duplicate(client, profile, operation, ctx, input, route)
|
|
256
|
+
r = {
|
|
257
|
+
schema: 1,
|
|
258
|
+
profile,
|
|
259
|
+
parentOperation: parent,
|
|
260
|
+
operation,
|
|
261
|
+
route,
|
|
262
|
+
input,
|
|
263
|
+
owner: ctx.parent.owner,
|
|
264
|
+
baseUrl: client.baseUrl,
|
|
265
|
+
binding: currentBinding(ctx),
|
|
266
|
+
state: 'prepared',
|
|
267
|
+
createdAt: new Date().toISOString()
|
|
268
|
+
}
|
|
269
|
+
await persist(r)
|
|
270
|
+
}
|
|
271
|
+
return confirmed
|
|
272
|
+
? recoverRepaintUpload(client, profile, operation, true)
|
|
273
|
+
: summary(r)
|
|
274
|
+
}
|
|
275
|
+
async function bound(client, profile, operation) {
|
|
276
|
+
const r = await json(repaintUploadPath(profile, operation), null)
|
|
277
|
+
if (
|
|
278
|
+
!r ||
|
|
279
|
+
r.schema !== 1 ||
|
|
280
|
+
r.profile !== profile ||
|
|
281
|
+
r.baseUrl !== client.baseUrl
|
|
282
|
+
)
|
|
283
|
+
throw new Error('上传operation不存在或服务不匹配')
|
|
284
|
+
const ctx = await repaintSettingsContext(client, profile, r.parentOperation)
|
|
285
|
+
const activation =
|
|
286
|
+
r.route === 'attachment' &&
|
|
287
|
+
!!(await json(
|
|
288
|
+
repaintSettingsPath(profile, r.operation + '-settings'),
|
|
289
|
+
null
|
|
290
|
+
))
|
|
291
|
+
check(r, ctx, activation)
|
|
292
|
+
return { r, ctx, activation }
|
|
293
|
+
}
|
|
294
|
+
export async function getRepaintUpload(client, profile, operation) {
|
|
295
|
+
const { r } = await bound(client, profile, operation)
|
|
296
|
+
return summary(r)
|
|
297
|
+
}
|
|
298
|
+
function sessionReceipt(s, r) {
|
|
299
|
+
if (
|
|
300
|
+
typeof s?.upload_id !== 'string' ||
|
|
301
|
+
!s.upload_id ||
|
|
302
|
+
typeof s.object_key !== 'string' ||
|
|
303
|
+
!s.object_key ||
|
|
304
|
+
!Number.isSafeInteger(s.part_size) ||
|
|
305
|
+
s.part_size < 1 ||
|
|
306
|
+
s.part_size > 128 * 1024 * 1024 ||
|
|
307
|
+
!Number.isSafeInteger(s.total_parts) ||
|
|
308
|
+
s.total_parts !== Math.ceil(r.input.bytes / s.part_size)
|
|
309
|
+
)
|
|
310
|
+
throw new Error('上传会话回执不完整')
|
|
311
|
+
return {
|
|
312
|
+
upload_id: s.upload_id,
|
|
313
|
+
object_key: s.object_key,
|
|
314
|
+
part_size: s.part_size,
|
|
315
|
+
total_parts: s.total_parts
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function verifyReceipt(r) {
|
|
319
|
+
const x = r.receipt
|
|
320
|
+
if (
|
|
321
|
+
!x ||
|
|
322
|
+
!x.file_ref ||
|
|
323
|
+
!x.url ||
|
|
324
|
+
x.file_size !== r.input.bytes ||
|
|
325
|
+
x.mime_type !== r.input.mime
|
|
326
|
+
)
|
|
327
|
+
throw new Error(
|
|
328
|
+
'注册回执缺少真实文件引用/大小/类型;保留原回执,不重发注册'
|
|
329
|
+
)
|
|
330
|
+
const url = new URL(x.url, new URL(r.baseUrl).origin)
|
|
331
|
+
if (
|
|
332
|
+
(url.origin !== new URL(r.baseUrl).origin && url.protocol !== 'https:') ||
|
|
333
|
+
!url.pathname.startsWith('/api/v1/files/') ||
|
|
334
|
+
decodeURIComponent(url.pathname.slice('/api/v1/files/'.length)) !==
|
|
335
|
+
x.file_ref ||
|
|
336
|
+
url.username ||
|
|
337
|
+
url.password ||
|
|
338
|
+
url.search ||
|
|
339
|
+
url.hash
|
|
340
|
+
)
|
|
341
|
+
throw new Error('注册回执不是当前服务对应文件代理引用')
|
|
342
|
+
if (
|
|
343
|
+
r.route === 'reference'
|
|
344
|
+
? !Number.isSafeInteger(x.id) ||
|
|
345
|
+
x.id < 1 ||
|
|
346
|
+
x.kind !== r.input.kind ||
|
|
347
|
+
x.category !== r.input.category ||
|
|
348
|
+
x.file_name !== r.input.filename
|
|
349
|
+
: x.name !== r.input.filename
|
|
350
|
+
)
|
|
351
|
+
throw new Error('注册回执用途/名称/素材ID不匹配')
|
|
352
|
+
}
|
|
353
|
+
export async function recoverRepaintUpload(
|
|
354
|
+
client,
|
|
355
|
+
profile,
|
|
356
|
+
operation,
|
|
357
|
+
confirmed = false
|
|
358
|
+
) {
|
|
359
|
+
await requireServiceReady(client)
|
|
360
|
+
let { r, ctx, activation } = await bound(client, profile, operation)
|
|
361
|
+
if (!confirmed) return summary(r)
|
|
362
|
+
if (r.state === 'completed') {
|
|
363
|
+
if (r.route === 'reference') await verifyReference(client, r)
|
|
364
|
+
else
|
|
365
|
+
r.activation = await appendRepaintAttachment(
|
|
366
|
+
client,
|
|
367
|
+
profile,
|
|
368
|
+
r.parentOperation,
|
|
369
|
+
r.operation + '-settings',
|
|
370
|
+
r.receipt
|
|
371
|
+
)
|
|
372
|
+
return summary(r)
|
|
373
|
+
}
|
|
374
|
+
await requireRepaintSettingsIdle(client, ctx, profile)
|
|
375
|
+
if (r.receipt) return finish(client, r)
|
|
376
|
+
const input = await repaintUploadInput(
|
|
377
|
+
{
|
|
378
|
+
file: r.input.file,
|
|
379
|
+
...(r.route === 'reference'
|
|
380
|
+
? { kind: r.input.kind, category: r.input.category }
|
|
381
|
+
: {})
|
|
382
|
+
},
|
|
383
|
+
r.route
|
|
384
|
+
)
|
|
385
|
+
if (!equal(r.input, input))
|
|
386
|
+
throw new Error('原本地文件内容已改变;不续传混合文件')
|
|
387
|
+
if (r.registerAttempted)
|
|
388
|
+
throw fail(
|
|
389
|
+
'原业务注册响应未知,不能按同名列表认领或重发注册;保留upload_id等待人工核验'
|
|
390
|
+
)
|
|
391
|
+
if (r.completeAttempted && !r.completeReceipt)
|
|
392
|
+
throw fail('原合并上传响应未知;不自动重发complete或创建新会话')
|
|
393
|
+
if (r.sessionAttempted && !r.session)
|
|
394
|
+
throw fail('原创建会话响应未知且无upload_id;不自动创建另一会话')
|
|
395
|
+
const purpose = r.route === 'reference' ? 'video_reference' : 'video_delivery'
|
|
396
|
+
try {
|
|
397
|
+
if (!r.session) {
|
|
398
|
+
r.sessionAttempted = true
|
|
399
|
+
r.state = 'creating_session'
|
|
400
|
+
await persist(r)
|
|
401
|
+
const s = await api(client, '/uploads/sessions', {
|
|
402
|
+
method: 'POST',
|
|
403
|
+
body: {
|
|
404
|
+
media_type:
|
|
405
|
+
r.route === 'reference'
|
|
406
|
+
? r.input.kind
|
|
407
|
+
: r.input.mime.startsWith('image/')
|
|
408
|
+
? 'image'
|
|
409
|
+
: 'other',
|
|
410
|
+
filename: r.input.filename,
|
|
411
|
+
file_size: r.input.bytes,
|
|
412
|
+
mime_type: r.input.mime,
|
|
413
|
+
purpose,
|
|
414
|
+
scope_id: r.binding.job_id
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
// Persist even a malformed receipt so the operation never creates again.
|
|
418
|
+
r.sessionRaw = {
|
|
419
|
+
upload_id: s?.upload_id,
|
|
420
|
+
object_key: s?.object_key,
|
|
421
|
+
part_size: s?.part_size,
|
|
422
|
+
total_parts: s?.total_parts
|
|
423
|
+
}
|
|
424
|
+
await persist(r)
|
|
425
|
+
r.session = sessionReceipt(s, r)
|
|
426
|
+
await persist(r)
|
|
427
|
+
}
|
|
428
|
+
const s = r.session,
|
|
429
|
+
upload = '/uploads/sessions/' + encodeURIComponent(s.upload_id)
|
|
430
|
+
if (!r.completeReceipt) {
|
|
431
|
+
const listed = await api(client, upload + '/parts')
|
|
432
|
+
if (
|
|
433
|
+
listed.upload_id !== s.upload_id ||
|
|
434
|
+
!Array.isArray(listed.parts) ||
|
|
435
|
+
!Array.isArray(listed.missing_parts)
|
|
436
|
+
)
|
|
437
|
+
throw new Error('上传分片查询结构不完整')
|
|
438
|
+
const parts = listed.parts.map((p) => ({
|
|
439
|
+
part_number: p.part_number,
|
|
440
|
+
etag: p.etag
|
|
441
|
+
}))
|
|
442
|
+
const numbers = parts.map((p) => p.part_number)
|
|
443
|
+
if (
|
|
444
|
+
new Set(numbers).size !== numbers.length ||
|
|
445
|
+
parts.some(
|
|
446
|
+
(p) =>
|
|
447
|
+
!Number.isInteger(p.part_number) ||
|
|
448
|
+
p.part_number < 1 ||
|
|
449
|
+
p.part_number > s.total_parts ||
|
|
450
|
+
!p.etag
|
|
451
|
+
) ||
|
|
452
|
+
!equal(
|
|
453
|
+
listed.missing_parts,
|
|
454
|
+
Array.from({ length: s.total_parts }, (_, i) => i + 1).filter(
|
|
455
|
+
(n) => !numbers.includes(n)
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
throw new Error('上传分片编号/缺片列表不匹配')
|
|
460
|
+
for (const p of r.parts || [])
|
|
461
|
+
if (
|
|
462
|
+
!parts.some(
|
|
463
|
+
(x) => x.part_number === p.part_number && x.etag === p.etag
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
throw new Error('已确认分片被修改或丢失,停止')
|
|
467
|
+
const handle = await fs.open(r.input.file, 'r')
|
|
468
|
+
try {
|
|
469
|
+
for (const n of listed.missing_parts) {
|
|
470
|
+
const size = Math.min(
|
|
471
|
+
s.part_size,
|
|
472
|
+
r.input.bytes - (n - 1) * s.part_size
|
|
473
|
+
),
|
|
474
|
+
bytes = Buffer.alloc(size)
|
|
475
|
+
const got = await handle.read(bytes, 0, size, (n - 1) * s.part_size)
|
|
476
|
+
if (got.bytesRead !== size) throw new Error('本地分片读取不完整')
|
|
477
|
+
const presign = await api(
|
|
478
|
+
client,
|
|
479
|
+
upload + '/parts/' + n + '/presign',
|
|
480
|
+
{ method: 'POST' }
|
|
481
|
+
)
|
|
482
|
+
if (presign.part_number !== n || typeof presign.url !== 'string')
|
|
483
|
+
throw new Error('分片签名回执无效')
|
|
484
|
+
r.state = 'uploading_parts'
|
|
485
|
+
r.partAttempted = n
|
|
486
|
+
await persist(r)
|
|
487
|
+
// Anonymous signed storage URL; never pass the API credentials.
|
|
488
|
+
const result = await request(
|
|
489
|
+
presign.url,
|
|
490
|
+
{ method: 'PUT', body: bytes },
|
|
491
|
+
client.timeout
|
|
492
|
+
)
|
|
493
|
+
if (!result.ok || !result.headers.get('etag'))
|
|
494
|
+
throw Object.assign(
|
|
495
|
+
new Error('分片PUT失败或缺ETag(HTTP ' + result.status + ')'),
|
|
496
|
+
{ status: result.status }
|
|
497
|
+
)
|
|
498
|
+
parts.push({ part_number: n, etag: result.headers.get('etag') })
|
|
499
|
+
r.parts = [...parts].sort((a, b) => a.part_number - b.part_number)
|
|
500
|
+
await persist(r)
|
|
501
|
+
}
|
|
502
|
+
} finally {
|
|
503
|
+
await handle.close()
|
|
504
|
+
}
|
|
505
|
+
const digest = await hashFile(r.input.file)
|
|
506
|
+
if (digest.bytes !== r.input.bytes || digest.sha256 !== r.input.sha256)
|
|
507
|
+
throw new Error('分片上传期间文件变化;不合并或注册')
|
|
508
|
+
r.parts = parts.sort((a, b) => a.part_number - b.part_number)
|
|
509
|
+
r.completeBody = {
|
|
510
|
+
object_key: s.object_key,
|
|
511
|
+
parts: r.parts,
|
|
512
|
+
expected_parts: s.total_parts
|
|
513
|
+
}
|
|
514
|
+
r.completeAttempted = true
|
|
515
|
+
r.state = 'completing'
|
|
516
|
+
await persist(r)
|
|
517
|
+
const c = await api(client, upload + '/complete', {
|
|
518
|
+
method: 'POST',
|
|
519
|
+
body: r.completeBody
|
|
520
|
+
})
|
|
521
|
+
r.completeRaw = c
|
|
522
|
+
await persist(r)
|
|
523
|
+
if (
|
|
524
|
+
c.upload_id !== s.upload_id ||
|
|
525
|
+
c.object_key !== s.object_key ||
|
|
526
|
+
c.session?.completed !== true ||
|
|
527
|
+
c.session.scope_id !== r.binding.job_id ||
|
|
528
|
+
c.session.purpose !== purpose ||
|
|
529
|
+
c.session.filename !== r.input.filename ||
|
|
530
|
+
c.session.file_size !== r.input.bytes ||
|
|
531
|
+
c.session.mime_type !== r.input.mime
|
|
532
|
+
)
|
|
533
|
+
throw new Error('合并回执未证实原会话/用途/文件绑定')
|
|
534
|
+
r.completeReceipt = true
|
|
535
|
+
r.state = 'uploaded'
|
|
536
|
+
await persist(r)
|
|
537
|
+
}
|
|
538
|
+
ctx = await repaintSettingsContext(client, profile, r.parentOperation)
|
|
539
|
+
check(r, ctx, activation)
|
|
540
|
+
await requireRepaintSettingsIdle(client, ctx, profile)
|
|
541
|
+
const fields = {
|
|
542
|
+
upload_id: r.session.upload_id,
|
|
543
|
+
...(r.route === 'reference'
|
|
544
|
+
? { kind: r.input.kind, category: r.input.category }
|
|
545
|
+
: { name: r.input.filename })
|
|
546
|
+
}
|
|
547
|
+
r.registerAttempted = true
|
|
548
|
+
r.state = 'registering'
|
|
549
|
+
await persist(r)
|
|
550
|
+
r.receipt = await api(
|
|
551
|
+
client,
|
|
552
|
+
jobPath(r.binding.job_id) +
|
|
553
|
+
(r.route === 'reference'
|
|
554
|
+
? '/reference-materials'
|
|
555
|
+
: '/delivery-attachment'),
|
|
556
|
+
{ method: 'POST', ...multipartText(fields) }
|
|
557
|
+
)
|
|
558
|
+
r.state = 'registered'
|
|
559
|
+
await persist(r)
|
|
560
|
+
} catch (cause) {
|
|
561
|
+
r.state = 'upload_pending'
|
|
562
|
+
await persist(r)
|
|
563
|
+
throw fail(
|
|
564
|
+
'上传阶段未完成;先检查后端连接,再按原operation查询/恢复,不换名或重复注册',
|
|
565
|
+
cause
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
return finish(client, r)
|
|
569
|
+
}
|
|
570
|
+
async function verifyReference(client, r) {
|
|
571
|
+
const listed = await api(
|
|
572
|
+
client,
|
|
573
|
+
jobPath(r.binding.job_id) + '/reference-materials'
|
|
574
|
+
)
|
|
575
|
+
const x = listed.items?.find((x) => x.id === r.receipt.id)
|
|
576
|
+
if (
|
|
577
|
+
!x ||
|
|
578
|
+
['kind', 'category', 'file_name', 'mime_type', 'file_size'].some(
|
|
579
|
+
(k) => x[k] !== r.receipt[k]
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
throw fail('已注册的任务素材ID未查到或绑定改变;不重新上传或按同名认领')
|
|
583
|
+
}
|
|
584
|
+
async function finish(client, r) {
|
|
585
|
+
verifyReceipt(r)
|
|
586
|
+
if (r.route === 'reference') await verifyReference(client, r)
|
|
587
|
+
else {
|
|
588
|
+
r.state = 'activation_pending'
|
|
589
|
+
await persist(r)
|
|
590
|
+
r.activation = await appendRepaintAttachment(
|
|
591
|
+
client,
|
|
592
|
+
r.profile,
|
|
593
|
+
r.parentOperation,
|
|
594
|
+
r.operation + '-settings',
|
|
595
|
+
r.receipt
|
|
596
|
+
)
|
|
597
|
+
}
|
|
598
|
+
r.state = 'completed'
|
|
599
|
+
await persist(r)
|
|
600
|
+
return summary(r)
|
|
601
|
+
}
|